@cosmicdrift/kumiko-guards 0.351.0 → 0.352.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-guards",
3
- "version": "0.351.0",
3
+ "version": "0.352.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.351.0",
31
+ "@cosmicdrift/kumiko-repo-manifest": "0.352.0",
32
32
  "ts-morph": "^28.0.0"
33
33
  },
34
34
  "publishConfig": {
package/src/changes.json CHANGED
@@ -1,4 +1,17 @@
1
1
  [
2
+ {
3
+ "version": "0.352.0",
4
+ "type": "improvement",
5
+ "title": "New No-Temporal-Polyfill-Import guard reports direct temporal-polyfill value imports outside kumiko-types",
6
+ "detail": "Scans source and test files (ts, tsx). Flags named, default, namespace and side-effect imports, re-exports, import() and require of \"temporal-polyfill\" or its subpaths, and prints the replacement import from @cosmicdrift/kumiko-types/temporal. Type-only imports and triple-slash type references pass."
7
+ },
8
+ {
9
+ "version": "0.352.0",
10
+ "type": "breaking",
11
+ "title": "upgrade-state guard fails on open manual upgrade steps",
12
+ "detail": "Every entry in the marker's pendingManual list now produces a guard violation until it is resolved.",
13
+ "migration": "Run `kumiko-upgrade --resolve <id> --reason \"<what you did>\"` for each open step once you applied it, or add `--not-applicable` when it does not concern your repo. The guard passes when no manual step is left open."
14
+ },
2
15
  {
3
16
  "version": "0.351.0",
4
17
  "type": "fix",
@@ -0,0 +1,113 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * Guard: finds value imports from `temporal-polyfill` outside kumiko-types.
4
+ *
5
+ * Since 0.351.0 `@cosmicdrift/kumiko-types/temporal` is the single source of
6
+ * the Temporal API: native `globalThis.Temporal` wins, the polyfill is only a
7
+ * fallback. A module that imports `Temporal` straight from `temporal-polyfill`
8
+ * gets the polyfill classes while framework-made values are native ones, so
9
+ * `instanceof Temporal.Instant` and `z.instanceof(Temporal.Instant)` reject
10
+ * them ("expected Instant, received Instant"). Side-effect imports such as
11
+ * `temporal-polyfill/global` are flagged too: they can put a second
12
+ * implementation on the global.
13
+ *
14
+ * Not flagged: `import type` (erased at runtime) and the
15
+ * `/// <reference types="temporal-polyfill/global" />` directive.
16
+ *
17
+ * Usage:
18
+ * bun guards/guard-no-temporal-polyfill-import.ts
19
+ *
20
+ * Exit 1 on violations, 0 when clean.
21
+ */
22
+
23
+ import { type ImportDeclaration, Node, type SourceFile, SyntaxKind } from "ts-morph";
24
+ import {
25
+ type AstGuard,
26
+ findRepoRootFor,
27
+ type GuardViolation,
28
+ relFromRepoRoot,
29
+ runStandalone,
30
+ type ScanSpec,
31
+ } from "./_lib/guard-kit";
32
+ import { type RepoRoot, resolveRepoRoots } from "./_lib/roots";
33
+
34
+ const SCAN: ScanSpec = {
35
+ scope: "source+tests",
36
+ extensions: ["ts", "tsx"],
37
+ frameworkWithin: ["packages/*/src/**", "samples/**"],
38
+ };
39
+
40
+ const REPLACEMENT_IMPORT = 'import { Temporal } from "@cosmicdrift/kumiko-types/temporal";';
41
+
42
+ // The one module that resolves native-vs-polyfill; repo-scoped so an app file
43
+ // at the same relative path does not inherit the exception.
44
+ const TEMPORAL_SOURCE = { repo: "kumiko-framework", relPath: "packages/types/src/temporal.ts" };
45
+
46
+ export function isTemporalSingleSource(repoName: string | undefined, relPath: string): boolean {
47
+ return repoName === TEMPORAL_SOURCE.repo && relPath === TEMPORAL_SOURCE.relPath;
48
+ }
49
+
50
+ function isPolyfillSpecifier(specifier: string): boolean {
51
+ return specifier === "temporal-polyfill" || specifier.startsWith("temporal-polyfill/");
52
+ }
53
+
54
+ function isTypeOnlyImport(decl: ImportDeclaration): boolean {
55
+ if (decl.isTypeOnly()) return true;
56
+ const named = decl.getNamedImports();
57
+ const hasValueBinding =
58
+ decl.getDefaultImport() !== undefined || decl.getNamespaceImport() !== undefined;
59
+ return !hasValueBinding && named.length > 0 && named.every((n) => n.isTypeOnly());
60
+ }
61
+
62
+ type PolyfillImport = { readonly line: number; readonly specifier: string };
63
+
64
+ function findPolyfillValueImports(sf: SourceFile): PolyfillImport[] {
65
+ const found: PolyfillImport[] = [];
66
+ for (const decl of sf.getImportDeclarations()) {
67
+ const specifier = decl.getModuleSpecifierValue();
68
+ if (isPolyfillSpecifier(specifier) && !isTypeOnlyImport(decl)) {
69
+ found.push({ line: decl.getStartLineNumber(), specifier });
70
+ }
71
+ }
72
+ for (const decl of sf.getExportDeclarations()) {
73
+ const specifier = decl.getModuleSpecifierValue();
74
+ if (specifier !== undefined && isPolyfillSpecifier(specifier) && !decl.isTypeOnly()) {
75
+ found.push({ line: decl.getStartLineNumber(), specifier });
76
+ }
77
+ }
78
+ for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
79
+ const callee = call.getExpression();
80
+ const isRequireCall = Node.isIdentifier(callee) && callee.getText() === "require";
81
+ const isDynamicImport = callee.getKind() === SyntaxKind.ImportKeyword;
82
+ if (!isRequireCall && !isDynamicImport) continue;
83
+ const arg = call.getArguments()[0];
84
+ if (arg && Node.isStringLiteral(arg) && isPolyfillSpecifier(arg.getLiteralValue())) {
85
+ found.push({ line: call.getStartLineNumber(), specifier: arg.getLiteralValue() });
86
+ }
87
+ }
88
+ return found;
89
+ }
90
+
91
+ export const guard: AstGuard = {
92
+ name: "No-Temporal-Polyfill-Import Guard",
93
+ scan: SCAN,
94
+ hint: `Import Temporal from the kumiko single source instead: ${REPLACEMENT_IMPORT} A direct "temporal-polyfill" import creates a second Temporal implementation, so instanceof and z.instanceof(Temporal.Instant) reject framework-made values.`,
95
+ run(files, roots: readonly RepoRoot[] = resolveRepoRoots()) {
96
+ const violations: GuardViolation[] = [];
97
+ for (const sf of files) {
98
+ const file = sf.getFilePath();
99
+ const rel = relFromRepoRoot(file, roots);
100
+ if (isTemporalSingleSource(findRepoRootFor(file, roots)?.name, rel)) continue;
101
+ for (const hit of findPolyfillValueImports(sf)) {
102
+ violations.push({
103
+ file: rel,
104
+ line: hit.line,
105
+ message: `[${hit.specifier}] direct temporal-polyfill import — use ${REPLACEMENT_IMPORT}`,
106
+ });
107
+ }
108
+ }
109
+ return { violations };
110
+ },
111
+ };
112
+
113
+ if (import.meta.main) runStandalone(guard);
@@ -36,11 +36,18 @@ const EXCLUDE = /(__tests__|\.test\.ts$|\.integration\.ts$|\.d\.ts$)/;
36
36
  const RESTRICTED_SYMBOLS = new Set([
37
37
  "getUnscopedAggregateStreamMaxVersion",
38
38
  "getUnscopedAggregateStreamTenant",
39
+ // Appends any event type without the feature-ownership check of ctx.appendEvent and without
40
+ // an audited unsafeRaw escape hatch, so new callers need an explicit allowlist entry.
41
+ "appendEventInTenantDb",
42
+ "getStreamVersionInTenantDb",
39
43
  ]);
40
44
 
41
45
  const ALLOWLIST = [
42
46
  /^packages\/framework\/src\/event-store\/event-store\.ts$/,
43
47
  /^packages\/framework\/src\/event-store\/index\.ts$/,
48
+ /^packages\/framework\/src\/event-store\/tenant-db-append\.ts$/,
49
+ /^packages\/bundled-features\/src\/cap-counter\/cap-reservation\.ts$/,
50
+ /^packages\/bundled-features\/src\/user-data-rights-defaults\/hooks\/delivery-attempt\.userdata-hook\.ts$/,
44
51
  /^packages\/bundled-features\/src\/tenant\/seeding\.ts$/,
45
52
  /^packages\/bundled-features\/src\/tier-engine\/feature\.ts$/,
46
53
  ];
@@ -112,7 +119,7 @@ function findRestrictedSymbolReferences(sf: SourceFile): Violation[] {
112
119
  export const guard: AstGuard = {
113
120
  name: "Restricted-Symbols Guard",
114
121
  scan: SCAN,
115
- hint: 'getUnscopedAggregateStream{MaxVersion,Tenant} is an existence oracle for foreign tenants — only seed-/system-internal code may reference them. New caller needed? Extend the allowlist in guard-restricted-symbols.ts, with a reason. Known gap: `export * from "...event-store"` is not detected (named imports, re-exports, and namespace property access are covered).',
122
+ hint: 'getUnscopedAggregateStream{MaxVersion,Tenant} is an existence oracle for foreign tenants — only seed-/system-internal code may reference them. appendEventInTenantDb/getStreamVersionInTenantDb bypass the feature-ownership check of ctx.appendEvent — only framework-internal coordination code may use them. New caller needed? Extend the allowlist in guard-restricted-symbols.ts, with a reason. Known gap: `export * from "...event-store"` is not detected (named imports, re-exports, and namespace property access are covered).',
116
123
  run(files, roots = resolveRepoRoots()) {
117
124
  const violations: Array<{ file: string; line: number; message: string }> = [];
118
125
 
@@ -125,7 +132,7 @@ export const guard: AstGuard = {
125
132
  violations.push({
126
133
  file: rel,
127
134
  line: v.line,
128
- message: `[${v.symbol}] unscoped stream-primitive referenced outside allowlist`,
135
+ message: `[${v.symbol}] restricted event-store primitive referenced outside allowlist`,
129
136
  });
130
137
  }
131
138
  }
@@ -7,7 +7,9 @@
7
7
  * `@cosmicdrift/kumiko-cli` or `@cosmicdrift/kumiko-dev-server`) and records
8
8
  * the version it was applied at. This guard re-runs `kumiko-upgrade --from <marker version> --json` and
9
9
  * fails if any changelog entries are still pending — meaning the marker is
10
- * stale and the repo hasn't run the upgrade since.
10
+ * stale and the repo hasn't run the upgrade since. It also fails once per open
11
+ * `pendingManual` step the marker still carries (breaking changes without a
12
+ * codemod that nobody resolved yet).
11
13
  *
12
14
  * Single-repo only, like `guard-upgrade-state.ts` in infra/guards but
13
15
  * without that package's multi-repo `resolveRepoRoots()` scan loop — this
@@ -31,7 +33,16 @@ import {
31
33
  const MARKER_REL = ".kumiko/upgrade-state.json";
32
34
  const SEMVER_RE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
33
35
 
34
- type UpgradeMarker = { readonly version: string };
36
+ type PendingManualStep = {
37
+ readonly id: string;
38
+ readonly version: string;
39
+ readonly title: string;
40
+ };
41
+
42
+ type UpgradeMarker = {
43
+ readonly version: string;
44
+ readonly pendingManual: readonly PendingManualStep[];
45
+ };
35
46
 
36
47
  type PendingEntry = {
37
48
  readonly version: string;
@@ -71,6 +82,25 @@ export function isUpgradeJson(value: unknown): value is UpgradeJson {
71
82
  );
72
83
  }
73
84
 
85
+ export function isPendingManualStep(value: unknown): value is PendingManualStep {
86
+ if (!value || typeof value !== "object") return false;
87
+ const v = value as Record<string, unknown>;
88
+ return (
89
+ typeof v["id"] === "string" &&
90
+ v["id"].length > 0 &&
91
+ typeof v["version"] === "string" &&
92
+ typeof v["title"] === "string"
93
+ );
94
+ }
95
+
96
+ function readPendingManual(parsed: unknown): readonly PendingManualStep[] {
97
+ const raw =
98
+ parsed && typeof parsed === "object"
99
+ ? (parsed as Record<string, unknown>)["pendingManual"]
100
+ : undefined;
101
+ return Array.isArray(raw) ? raw.filter(isPendingManualStep) : [];
102
+ }
103
+
74
104
  export function readMarker(root: string): UpgradeMarker | { error: string } {
75
105
  const markerPath = join(root, MARKER_REL);
76
106
  if (!existsSync(markerPath)) {
@@ -93,7 +123,7 @@ export function readMarker(root: string): UpgradeMarker | { error: string } {
93
123
  error: `${MARKER_REL} is missing a valid "version" field (expected semver x.y.z[-pre][+build]) — broken marker file`,
94
124
  };
95
125
  }
96
- return { version };
126
+ return { version, pendingManual: readPendingManual(parsed) };
97
127
  }
98
128
 
99
129
  /** Pending changelog entries turned into guard violations — pure, testable without a subprocess. */
@@ -109,6 +139,20 @@ export function pendingViolations(json: UpgradeJson, markerVersion: string): Gua
109
139
  }));
110
140
  }
111
141
 
142
+ /** Open manual upgrade steps from the marker, one violation each. */
143
+ export function pendingManualViolations(
144
+ pendingManual: readonly PendingManualStep[],
145
+ ): GuardViolation[] {
146
+ return pendingManual.map((step) => ({
147
+ file: MARKER_REL,
148
+ line: 1,
149
+ message:
150
+ `Open manual upgrade step ${step.id} (${step.version}): ${step.title}. ` +
151
+ `Resolve it with \`kumiko-upgrade --resolve ${step.id} --reason "<what you did>"\` ` +
152
+ `or mark it with \`--not-applicable\`.`,
153
+ }));
154
+ }
155
+
112
156
  export function resolveKumikoUpgradeBin(
113
157
  cwd: string,
114
158
  pathEnv: string | undefined = process.env["PATH"],
@@ -204,16 +248,17 @@ export const check: RepoCheck = {
204
248
  notApplicable: false,
205
249
  };
206
250
  }
251
+ const manualViolations = pendingManualViolations(marker.pendingManual);
207
252
  const result = await runKumikoUpgrade(marker.version, rootAbsPath);
208
253
  if (!result.ok) {
209
254
  return {
210
- violations: [{ file: MARKER_REL, line: 1, message: result.error }],
255
+ violations: [{ file: MARKER_REL, line: 1, message: result.error }, ...manualViolations],
211
256
  matchedFiles: 1,
212
257
  notApplicable: false,
213
258
  };
214
259
  }
215
260
  return {
216
- violations: pendingViolations(result.json, marker.version),
261
+ violations: [...pendingViolations(result.json, marker.version), ...manualViolations],
217
262
  matchedFiles: 1,
218
263
  notApplicable: false,
219
264
  };
package/src/run-guards.ts CHANGED
@@ -42,6 +42,7 @@ import { guard as loadallEvents } from "./guard-loadall-events";
42
42
  import { guard as noDateApi } from "./guard-no-date-api";
43
43
  import { guard as noDirectFs } from "./guard-no-direct-fs";
44
44
  import { guard as noLogicInViews } from "./guard-no-logic-in-views";
45
+ import { guard as noTemporalPolyfillImport } from "./guard-no-temporal-polyfill-import";
45
46
  import { guard as openToAllReason } from "./guard-open-to-all-reason";
46
47
  import { guard as piiAnnotations } from "./guard-pii-annotations";
47
48
  import { guard as preEsPatterns } from "./guard-pre-es-patterns";
@@ -72,6 +73,7 @@ export const GUARDS = [
72
73
  htmlEscape,
73
74
  crossFeatureImports,
74
75
  noDateApi,
76
+ noTemporalPolyfillImport,
75
77
  restrictedSymbols,
76
78
  fakeTests,
77
79
  noLogicInViews,