@cosmicdrift/kumiko-guards 0.322.0 → 0.323.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.322.0",
3
+ "version": "0.323.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.322.0",
31
+ "@cosmicdrift/kumiko-repo-manifest": "0.323.0",
32
32
  "ts-morph": "^28.0.0"
33
33
  },
34
34
  "publishConfig": {
@@ -255,6 +255,10 @@ export type RunResult = {
255
255
  readonly message?: string;
256
256
  /** No root resolved at all: the target repos are not in this checkout. */
257
257
  readonly notApplicable?: boolean;
258
+ /** Why `notApplicable` is set — `runGuards` only; `runRepoChecks` leaves this undefined. */
259
+ readonly skipReason?: "repos-missing" | "outside-kinds";
260
+ /** The guard's `scan.kinds`, for the "outside-kinds" skip message. */
261
+ readonly scanKinds?: readonly string[];
258
262
  /** Files the scan matched — the runner's own count, basis of the floor. */
259
263
  readonly matchedFiles?: number;
260
264
  /** Roots whose declared sourceRoots hold no .ts/.tsx at all (D4, infra#427). */
@@ -333,13 +337,20 @@ export function runGuards(
333
337
  ? { violations: securityResult.blocking }
334
338
  : outcome;
335
339
  const { violatingRoots } = checkRootFloor(guard, scans);
340
+ const notApplicable = scans.length === 0;
336
341
  results.push({
337
342
  name: guard.name,
338
343
  ok: effectiveOutcome.violations.length === 0 && violatingRoots.length === 0,
339
344
  ms: Math.round(performance.now() - start),
340
345
  outcome: effectiveOutcome,
341
346
  hint: guard.hint,
342
- notApplicable: scans.length === 0,
347
+ notApplicable,
348
+ skipReason: notApplicable
349
+ ? roots.length === 0
350
+ ? "repos-missing"
351
+ : "outside-kinds"
352
+ : undefined,
353
+ scanKinds: notApplicable ? guard.scan.kinds : undefined,
343
354
  matchedFiles: files.length,
344
355
  violatingRoots: violatingRoots.length > 0 ? violatingRoots : undefined,
345
356
  frozenFindings: securityResult?.frozen,
@@ -600,7 +611,9 @@ export function reportResults(results: readonly RunResult[]): number {
600
611
  // Not applicable does not mean checked — that must stay visible,
601
612
  // otherwise a standalone run reads like a complete one.
602
613
  const scope = r.notApplicable
603
- ? " — skipped, target repos not in checkout"
614
+ ? r.skipReason === "outside-kinds"
615
+ ? ` — skipped, repo kind outside guard kinds (${(r.scanKinds ?? []).join(", ")})`
616
+ : " — skipped, target repos not in checkout"
604
617
  : ` (${r.matchedFiles ?? 0} files)`;
605
618
  const frozen =
606
619
  r.frozenFindings && r.frozenFindings > 0
package/src/changes.json CHANGED
@@ -1,4 +1,16 @@
1
1
  [
2
+ {
3
+ "version": "0.323.0",
4
+ "type": "improvement",
5
+ "title": "guard-fake-tests now also runs in app repos, counts same-file assertion helpers and the writeOk/writeErr/queryOk/queryErr test APIs as assertions; skip output distinguishes \"outside guard kinds\" from \"target repos missing\"",
6
+ "migration": "A same-file helper function (or arrow/function-expression variable) whose\nbody calls `expect(...)` — directly or through up to two more levels of\nsame-file helpers — now counts as an assertion, same as calling `expect`\nin the test body itself. `stack.http.writeOk/writeErr` and\n`tenant.api.queryOk/queryErr` (matched on the last property name of the\ncallee) count as assertions too, since they throw on the wrong outcome.\nA bare Testing Library `waitFor(() => screen.getByTestId(...))` (or any\nother `getBy*`/`getAllBy*` query call) does NOT count as an assertion —\nit still needs its own `expect(...)` to prove the wait actually found\nsomething. The guard now also scans `kind: \"app\"` repos and `.tsx` test files,\nnot just framework/library `.ts`. `reportResults` now prints \"skipped,\nrepo kind outside guard kinds (<kinds>)\" instead of \"target repos not in\ncheckout\" when the checkout has repo roots but none match the guard's\n`kinds` — the missing-checkout case keeps its original message."
7
+ },
8
+ {
9
+ "version": "0.323.0",
10
+ "type": "improvement",
11
+ "title": "Text-Field Personal-Stance Guard exempts the two framework tests that assert the throw",
12
+ "detail": "`guard-text-field-stance.ts` now skips an explicit, exact\nrepo-relative-path allowlist\n(`packages/framework/src/engine/__tests__/factories-long-text.test.ts`,\n`packages/framework/src/engine/__tests__/factories-personal.test.ts`) —\nthese two deliberately call `createTextField`/`createLongTextField`\nwithout a stance to prove the fail-closed throw. A different file\nsharing one of those basenames is not exempt; the match is on the exact\nrepo-relative path. The guard also scans `samples/**` and `demo/**`\nnow, not only `packages/*/src/**`. The baseline\n(`.kumiko-text-field-stance-baseline.json`) drops to 0."
13
+ },
2
14
  {
3
15
  "version": "0.321.0",
4
16
  "type": "improvement",
@@ -20,8 +20,8 @@ const ROOT = process.cwd();
20
20
 
21
21
  const SCAN: ScanSpec = {
22
22
  scope: "tests",
23
- extensions: ["ts"],
24
- kinds: ["framework", "library"],
23
+ extensions: ["ts", "tsx"],
24
+ kinds: ["framework", "library", "app"],
25
25
  };
26
26
 
27
27
  interface Violation {
@@ -59,19 +59,60 @@ const ASSERTION_HELPER_NAMES = new Set([
59
59
  "expectTypeOf",
60
60
  ]);
61
61
 
62
- function countExpects(node: Node): number {
63
- let n = 0;
64
- node.forEachDescendant((d) => {
65
- if (d.isKind(SyntaxKind.CallExpression)) {
66
- const name = d.getExpression().getText();
67
- if (name === "expect" || name.startsWith("expect.")) {
68
- n++;
69
- } else if (ASSERTION_HELPER_NAMES.has(name)) {
70
- n++;
71
- }
72
- }
73
- });
74
- return n;
62
+ // Matched on the LAST property name of a property-access callee
63
+ // (`stack.http.writeOk(...)`, `tenant.api.queryErr(...)`) — these throw on
64
+ // the wrong outcome, same as expect(), so a call to one counts as an
65
+ // assertion (framework request-helper.ts, testing auth-kit.ts successData).
66
+ const ASSERTION_API_METHODS = new Set(["writeOk", "writeErr", "queryOk", "queryErr"]);
67
+
68
+ const MAX_HELPER_DEPTH = 3;
69
+
70
+ function isDirectAssertionCall(call: CallExpression): boolean {
71
+ const expr = call.getExpression();
72
+ const text = expr.getText();
73
+ if (text === "expect" || text.startsWith("expect.")) return true;
74
+ if (ASSERTION_HELPER_NAMES.has(text)) return true;
75
+ if (!expr.isKind(SyntaxKind.PropertyAccessExpression)) return false;
76
+ const name = expr.getName();
77
+ return ASSERTION_API_METHODS.has(name);
78
+ }
79
+
80
+ // A bare identifier `foo(...)` counts as an assertion when `foo` is declared
81
+ // in the SAME file (function declaration, or a variable initialized with an
82
+ // arrow/function expression, at any nesting level) and its own body asserts —
83
+ // recursively, so a chain of thin wrappers around expect() still counts.
84
+ function resolveSameFileHelper(sf: SourceFile, name: string): Node | undefined {
85
+ const fn = sf
86
+ .getDescendantsOfKind(SyntaxKind.FunctionDeclaration)
87
+ .find((f) => f.getName() === name);
88
+ if (fn) return fn;
89
+ const varDecl = sf
90
+ .getDescendantsOfKind(SyntaxKind.VariableDeclaration)
91
+ .find((v) => v.getName() === name);
92
+ const init = varDecl?.getInitializer();
93
+ if (
94
+ init &&
95
+ (init.isKind(SyntaxKind.ArrowFunction) || init.isKind(SyntaxKind.FunctionExpression))
96
+ ) {
97
+ return init;
98
+ }
99
+ return undefined;
100
+ }
101
+
102
+ // depth limit + visited set: a helper chain deeper than the limit is treated
103
+ // as unproven (violation), and a recursive helper never infinite-loops.
104
+ function bodyHasAssertion(node: Node, sf: SourceFile, depth: number, visited: Set<Node>): boolean {
105
+ if (visited.has(node)) return false;
106
+ visited.add(node);
107
+ for (const call of node.getDescendantsOfKind(SyntaxKind.CallExpression)) {
108
+ if (isDirectAssertionCall(call)) return true;
109
+ if (depth >= MAX_HELPER_DEPTH) continue;
110
+ const expr = call.getExpression();
111
+ if (!expr.isKind(SyntaxKind.Identifier)) continue;
112
+ const helper = resolveSameFileHelper(sf, expr.getText());
113
+ if (helper && bodyHasAssertion(helper, sf, depth + 1, visited)) return true;
114
+ }
115
+ return false;
75
116
  }
76
117
 
77
118
  function scanFile(sf: SourceFile): Violation[] {
@@ -100,8 +141,8 @@ function scanFile(sf: SourceFile): Violation[] {
100
141
  const isArrowOrFn =
101
142
  body.isKind(SyntaxKind.ArrowFunction) || body.isKind(SyntaxKind.FunctionExpression);
102
143
  if (!isArrowOrFn) continue;
103
- const nExpect = countExpects(body);
104
- if (nExpect === 0) {
144
+ const hasAssertion = bodyHasAssertion(body, sf, 0, new Set());
145
+ if (!hasAssertion) {
105
146
  const nameArg = args[0]?.getText().slice(0, 50) ?? "<anonymous>";
106
147
  violations.push({
107
148
  file: path.relative(ROOT, sf.getFilePath()),
@@ -50,7 +50,11 @@ const ROOT = process.cwd();
50
50
  const SCAN: ScanSpec = {
51
51
  scope: "source",
52
52
  extensions: ["ts"],
53
- frameworkWithin: ["packages/*/src/**"],
53
+ frameworkWithin: ["packages/*/src/**", "samples/**", "demo/**"],
54
+ // `demo/` isn't a declared sourceRoot (kumiko.json) — extraGlobs pulls its
55
+ // files in on top of the scope's own hits; frameworkWithin above still
56
+ // gates them the same as every other hit.
57
+ extraGlobs: ["demo/**/*.ts"],
54
58
  };
55
59
  // Unlike guard-pii-annotations.ts, tests are IN scope: kumiko-framework#2810's
56
60
  // fail-closed throw fires at call time regardless of test vs. production code —
@@ -60,6 +64,14 @@ const EXCLUDE = /\.d\.ts$/;
60
64
 
61
65
  const FIELD_FACTORY_CALLEES = new Set(["createTextField", "createLongTextField"]);
62
66
 
67
+ // These two files deliberately call the factories without a stance to prove
68
+ // the fail-closed throw (kumiko-framework#2921) — exact repo-relative match,
69
+ // not basename, so a consumer file sharing a name stays flagged.
70
+ const STANCE_THROW_TEST_ALLOWLIST = new Set<string>([
71
+ "packages/framework/src/engine/__tests__/factories-long-text.test.ts",
72
+ "packages/framework/src/engine/__tests__/factories-personal.test.ts",
73
+ ]);
74
+
63
75
  const VALID_PERSONAL_HINT =
64
76
  '{ personal: "self" | "tenant" | "ref" | { of: "<ownerField>" } | false } ("false" additionally needs { reason: "..." })';
65
77
 
@@ -114,6 +126,9 @@ export interface Finding {
114
126
 
115
127
  function scanFieldFactories(sf: SourceFile, roots: readonly RepoRoot[]): Finding[] {
116
128
  const findings: Finding[] = [];
129
+ const file = relFile(sf, roots);
130
+ if (STANCE_THROW_TEST_ALLOWLIST.has(file)) return findings;
131
+
117
132
  for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
118
133
  const callee = call.getExpression().getText();
119
134
  if (!FIELD_FACTORY_CALLEES.has(callee)) continue;
@@ -128,7 +143,6 @@ function scanFieldFactories(sf: SourceFile, roots: readonly RepoRoot[]): Finding
128
143
 
129
144
  const place = enclosingFieldName(call) ?? `${callee}(...)`;
130
145
  const line = call.getStartLineNumber();
131
- const file = relFile(sf, roots);
132
146
  findings.push({ file, line, place, callee });
133
147
  console.warn(
134
148
  ` [text-field-stance WARN] ${file}:${line} ${callee}(...) at "${place}" has no personal stance — mark ${VALID_PERSONAL_HINT}`,
@@ -137,7 +151,7 @@ function scanFieldFactories(sf: SourceFile, roots: readonly RepoRoot[]): Finding
137
151
  return findings;
138
152
  }
139
153
 
140
- function scan(
154
+ export function scan(
141
155
  files: readonly SourceFile[],
142
156
  roots: readonly RepoRoot[] = resolveRepoRoots(),
143
157
  ): {
@@ -85,15 +85,16 @@ export function classifyByPath(repoRelativePath: string): Runtime | null {
85
85
  }
86
86
 
87
87
  /**
88
- * App-repo browser bundle entry, mirroring kumiko-build's own discovery
89
- * (`discoverClientEntries` in kumiko-framework's
88
+ * App-repo browser bundle entry, mirroring the naming convention apps use
89
+ * for `kumiko-build` entries (`resolveClientEntries` in kumiko-framework's
90
90
  * `packages/server-runtime/src/build-prod-bundle.ts`): `src/client.tsx`/
91
- * `src/client.ts` (single-entry) or `src/client-<suffix>.tsx?` (multi-entry).
92
- * Framework/enterprise packages never match — their sources live under
93
- * `packages/*\/src/`, not a repo-root `src/`. Kept independent of the
94
- * framework's own regex (cross-package import would be a build-vs-lint
95
- * layering violation) — if kumiko-build's discovery pattern changes, this
96
- * drifts and needs a matching update.
91
+ * `src/client.ts` (single-entry) or `src/client-<suffix>.tsx?` (declared via
92
+ * package.json `kumiko.clientEntries`, fw#2320 — no longer auto-discovered
93
+ * by this filename alone). Framework/enterprise packages never match —
94
+ * their sources live under `packages/*\/src/`, not a repo-root `src/`. Kept
95
+ * independent of the framework's own regex (cross-package import would be a
96
+ * build-vs-lint layering violation) — if apps stop following this naming
97
+ * convention, this drifts and needs a matching update.
97
98
  */
98
99
  export function isClientEntryPath(repoRelativePath: string): boolean {
99
100
  const rel = repoRelativePath.replace(/\\/g, "/");