@cosmicdrift/kumiko-guards 0.331.0 → 0.332.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.
@@ -32,6 +32,9 @@
32
32
 
33
33
  import * as path from "node:path";
34
34
  import {
35
+ type ArrowFunction,
36
+ type FunctionDeclaration,
37
+ type FunctionExpression,
35
38
  type Identifier,
36
39
  Node,
37
40
  type SourceFile,
@@ -155,12 +158,60 @@ function isSafeLocalIdentifier(ident: Identifier, sf: SourceFile, depth: number)
155
158
  });
156
159
  }
157
160
 
158
- function calleeRootIdentifier(expr: Node): Identifier | undefined {
159
- let cur = expr;
160
- while (cur.isKind(SyntaxKind.PropertyAccessExpression)) {
161
- cur = cur.getExpression();
161
+ function isSafeTemplateTag(tag: Node): boolean {
162
+ const name = tag.isKind(SyntaxKind.PropertyAccessExpression)
163
+ ? tag.getNameNode().getText()
164
+ : tag.getText();
165
+ return SAFE_TEMPLATE_TAGS.has(name);
166
+ }
167
+
168
+ type LocalFunction = FunctionDeclaration | ArrowFunction | FunctionExpression;
169
+
170
+ function localFunctionsOf(callee: Node, sf: SourceFile): LocalFunction[] | undefined {
171
+ if (!callee.isKind(SyntaxKind.Identifier)) return undefined;
172
+ const decls = localDeclarations(callee as Identifier, sf);
173
+ if (decls.length === 0) return undefined;
174
+ const fns: LocalFunction[] = [];
175
+ for (const d of decls) {
176
+ if (d.isKind(SyntaxKind.FunctionDeclaration)) {
177
+ fns.push(d);
178
+ continue;
179
+ }
180
+ const init = d.isKind(SyntaxKind.VariableDeclaration) ? d.getInitializer() : undefined;
181
+ if (init?.isKind(SyntaxKind.ArrowFunction) || init?.isKind(SyntaxKind.FunctionExpression)) {
182
+ fns.push(init);
183
+ continue;
184
+ }
185
+ return undefined;
186
+ }
187
+ return fns;
188
+ }
189
+
190
+ function returnExpressionsOf(fn: LocalFunction): Node[] | undefined {
191
+ const body = fn.getBody();
192
+ if (!body) return undefined;
193
+ if (!Node.isBlock(body)) return [body];
194
+ const out: Node[] = [];
195
+ for (const ret of body.getDescendantsOfKind(SyntaxKind.ReturnStatement)) {
196
+ // Returns of nested functions belong to those, not to `fn`.
197
+ if (ret.getFirstAncestor((a) => Node.isFunctionLikeDeclaration(a)) !== fn) continue;
198
+ const expr = ret.getExpression();
199
+ if (expr) out.push(expr);
162
200
  }
163
- return cur.isKind(SyntaxKind.Identifier) ? (cur as Identifier) : undefined;
201
+ return out;
202
+ }
203
+
204
+ // A call to a directly named local function is trusted only when everything
205
+ // it returns is itself safe — `function render(x) { return x; }` is not.
206
+ // Methods on classes/objects/namespaces (`Templates.render(...)`) are never
207
+ // trusted by their root alone.
208
+ function isSafeLocalCall(callee: Node, sf: SourceFile, depth: number): boolean {
209
+ const fns = localFunctionsOf(callee, sf);
210
+ if (!fns) return false;
211
+ return fns.every((fn) => {
212
+ const returns = returnExpressionsOf(fn);
213
+ return returns?.every((r) => isSafeExpression(r, sf, depth + 1)) === true;
214
+ });
164
215
  }
165
216
 
166
217
  function isSafeExpression(expr: Node, sf: SourceFile, depth: number): boolean {
@@ -176,14 +227,17 @@ function isSafeExpression(expr: Node, sf: SourceFile, depth: number): boolean {
176
227
  ) {
177
228
  return true;
178
229
  }
179
- // Nested template literals are scanned as their own candidates.
180
- if (
181
- expr.isKind(SyntaxKind.TemplateExpression) ||
182
- expr.isKind(SyntaxKind.NoSubstitutionTemplateLiteral) ||
183
- expr.isKind(SyntaxKind.TaggedTemplateExpression)
184
- ) {
185
- return true;
230
+ if (expr.isKind(SyntaxKind.NoSubstitutionTemplateLiteral)) return true;
231
+ // A template with an HTML tag is scanned as its own candidate (incl. its
232
+ // html-ok comments). One without is never scanned, so its interpolations
233
+ // must be safe here.
234
+ if (expr.isKind(SyntaxKind.TemplateExpression)) {
235
+ if (HTML_TAG.test(templateStaticText(expr))) return true;
236
+ return expr
237
+ .getTemplateSpans()
238
+ .every((span) => isSafeExpression(span.getExpression(), sf, depth + 1));
186
239
  }
240
+ if (expr.isKind(SyntaxKind.TaggedTemplateExpression)) return isSafeTemplateTag(expr.getTag());
187
241
  if (expr.isKind(SyntaxKind.ConditionalExpression)) {
188
242
  return (
189
243
  isSafeExpression(expr.getWhenTrue(), sf, depth + 1) &&
@@ -202,6 +256,14 @@ function isSafeExpression(expr: Node, sf: SourceFile, depth: number): boolean {
202
256
  isSafeExpression(expr.getRight(), sf, depth + 1)
203
257
  );
204
258
  }
259
+ // String concatenation is safe when both operands are.
260
+ if (
261
+ op === SyntaxKind.PlusToken &&
262
+ isSafeExpression(expr.getLeft(), sf, depth + 1) &&
263
+ isSafeExpression(expr.getRight(), sf, depth + 1)
264
+ ) {
265
+ return true;
266
+ }
205
267
  // Arithmetic (`${width / 2}`) is safe as long as the result type is number.
206
268
  return isCompileTimeKnownType(expr.getType());
207
269
  }
@@ -244,8 +306,7 @@ function isSafeExpression(expr: Node, sf: SourceFile, depth: number): boolean {
244
306
  }
245
307
  }
246
308
  if (endsWithHtmlConvention(callee)) return true;
247
- const root = calleeRootIdentifier(callee);
248
- if (root && isSafeLocalIdentifier(root, sf, depth)) return true;
309
+ if (isSafeLocalCall(callee, sf, depth)) return true;
249
310
  return isCompileTimeKnownType(expr.getType());
250
311
  }
251
312
  if (expr.isKind(SyntaxKind.Identifier)) {
@@ -280,11 +341,7 @@ function isInsideErrorConstruction(tpl: Node): boolean {
280
341
  function isInsideSafeTag(tpl: Node): boolean {
281
342
  const parent = tpl.getParent();
282
343
  if (!parent?.isKind(SyntaxKind.TaggedTemplateExpression)) return false;
283
- const tag = parent.getTag();
284
- const name = tag.isKind(SyntaxKind.PropertyAccessExpression)
285
- ? tag.getNameNode().getText()
286
- : tag.getText();
287
- return SAFE_TEMPLATE_TAGS.has(name);
344
+ return isSafeTemplateTag(parent.getTag());
288
345
  }
289
346
 
290
347
  function hasHtmlOkComment(lines: readonly string[], line: number): boolean {
@@ -36,14 +36,20 @@ const SCAN: ScanSpec = {
36
36
  scope: "source",
37
37
  extensions: ["ts", "tsx"],
38
38
  kinds: ["library", "app"],
39
- within: ["lib/**/*.ts", "features/*/lib/**/*.ts", "**/*.test.ts", "**/*.test.tsx"],
39
+ within: ["lib/**/*.{ts,tsx}", "**/lib/**/*.{ts,tsx}", "**/*.test.ts", "**/*.test.tsx"],
40
40
  };
41
41
  const IGNORE_TAG = "kumiko-lint-ignore lib-test-coverage";
42
42
 
43
43
  type Export = { readonly name: string; readonly node: Node };
44
44
 
45
45
  function isTestFile(path: string): boolean {
46
- return /\.test\.tsx?$/.test(path) || /\/__tests__\//.test(path);
46
+ return /\.test\.tsx?$/.test(path);
47
+ }
48
+
49
+ // Fixtures/helpers under __tests__/ without a .test. suffix are neither tests
50
+ // (they must not satisfy the coverage link) nor lib sources (no test duty).
51
+ function isTestSupportFile(path: string): boolean {
52
+ return /\/__tests__\//.test(path);
47
53
  }
48
54
 
49
55
  // Exportierte Callables: `export function f` und `export const f = () => …` /
@@ -126,7 +132,8 @@ export const guard: AstGuard = {
126
132
  const libFiles: SourceFile[] = [];
127
133
  for (const sf of files) {
128
134
  if (isTestFile(sf.getFilePath())) testFiles.push(sf);
129
- else if (!sf.getFilePath().endsWith(".d.ts")) libFiles.push(sf);
135
+ else if (!sf.getFilePath().endsWith(".d.ts") && !isTestSupportFile(sf.getFilePath()))
136
+ libFiles.push(sf);
130
137
  }
131
138
  for (const sf of libFiles) {
132
139
  const relevant = exportedCallables(sf).filter((e) => !hasIgnoreTag(e.node, IGNORE_TAG));
@@ -20,7 +20,13 @@
20
20
 
21
21
  import * as path from "node:path";
22
22
  import { type CallExpression, type Identifier, type SourceFile, SyntaxKind } from "ts-morph";
23
- import { type AstGuard, type GuardViolation, runStandalone, type ScanSpec } from "./_lib/guard-kit";
23
+ import {
24
+ type AstGuard,
25
+ type GuardViolation,
26
+ isLocalFinding,
27
+ runStandalone,
28
+ type ScanSpec,
29
+ } from "./_lib/guard-kit";
24
30
 
25
31
  const ROOT = process.cwd();
26
32
 
@@ -118,7 +124,7 @@ export const guard: AstGuard = {
118
124
  const violations: GuardViolation[] = [];
119
125
  for (const sf of files) {
120
126
  if (EXCLUDE.test(sf.getFilePath())) continue;
121
- for (const v of collectViolations(sf)) {
127
+ for (const v of collectViolations(sf).filter(isLocalFinding)) {
122
128
  violations.push({
123
129
  file: v.file,
124
130
  line: v.line,
@@ -44,7 +44,8 @@ export type ProcessEnvFinding = {
44
44
 
45
45
  /** Returns true when the line contains a live process.env reference. */
46
46
  export function processEnvOnLine(line: string): boolean {
47
- const code = line.replace(/\/\/.*$/, "");
47
+ // `(?<!:)` keeps `https://…` inside string literals from being cut as a comment.
48
+ const code = line.replace(/(?<!:)\/\/.*$/, "");
48
49
  return PROCESS_ENV_REF.test(code);
49
50
  }
50
51
 
@@ -236,7 +236,11 @@ function isDirectlyUnderSlotsHeader(obj: ObjectLiteralExpression): boolean {
236
236
  const slotsObject = headerAssignment.getFirstAncestorByKind(SyntaxKind.ObjectLiteralExpression);
237
237
  if (slotsObject === undefined) return false;
238
238
  const slotsAssignment = slotsObject.getParentIfKind(SyntaxKind.PropertyAssignment);
239
- return slotsAssignment !== undefined && slotsAssignment.getName() === "slots";
239
+ if (slotsAssignment === undefined || slotsAssignment.getName() !== "slots") return false;
240
+ // Only entityEdit screens render `slots.header` as a bare header slot; other
241
+ // screen types (projectionDetail, entityList) use `slots` differently.
242
+ const screenObject = slotsAssignment.getParentIfKind(SyntaxKind.ObjectLiteralExpression);
243
+ return screenObject !== undefined && stringPropertyValue(screenObject, "type") === "entityEdit";
240
244
  }
241
245
 
242
246
  function mountKindFor(componentPropertyAssignment: Node): MountKind {
@@ -175,14 +175,11 @@ function scanFieldFactories(sf: SourceFile, roots: readonly RepoRoot[]): Finding
175
175
  const line = call.getStartLineNumber();
176
176
  const file = relFile(sf, roots);
177
177
  findings.push({ file, line, fieldName, hint });
178
- console.warn(
179
- ` [pii-annotations WARN] ${file}:${line} field "${fieldName}" looks like PII — mark ${hint}`,
180
- );
181
178
  }
182
179
  return findings;
183
180
  }
184
181
 
185
- function scan(
182
+ export function scan(
186
183
  files: readonly SourceFile[],
187
184
  roots: readonly RepoRoot[] = resolveRepoRoots(),
188
185
  ): {
@@ -243,6 +240,10 @@ function analyse(
243
240
  ): GuardOutcome {
244
241
  const { findings } = scan(files, roots);
245
242
  if (!compareBaseline) {
243
+ for (const f of findings)
244
+ console.warn(
245
+ ` [pii-annotations WARN] ${f.file}:${f.line} field "${f.fieldName}" looks like PII — mark ${f.hint}`,
246
+ );
246
247
  console.log(" Baseline comparison skipped (--no-baseline).");
247
248
  return { violations: [] };
248
249
  }
@@ -92,6 +92,8 @@ export type ScopeKind = "bundled-features" | "samples" | "app";
92
92
  type ScanScope = {
93
93
  readonly kind: ScopeKind;
94
94
  readonly root: string;
95
+ /** Repo root the reported file labels are relative to. */
96
+ readonly repoRoot: string;
95
97
  };
96
98
 
97
99
  // Framework checkout enforces bundled-features + samples. App-repo checkout
@@ -103,29 +105,29 @@ type ScanScope = {
103
105
  function resolveScopes(roots: readonly RepoRoot[]): ScanScope[] {
104
106
  const frameworkRoot = roots.find((r) => r.kind === "framework");
105
107
  if (frameworkRoot) {
108
+ const repoRoot = frameworkRoot.absPath;
106
109
  return [
107
110
  {
108
111
  kind: "bundled-features",
109
- root: path.join(frameworkRoot.absPath, "packages/bundled-features/src"),
112
+ root: path.join(repoRoot, "packages/bundled-features/src"),
113
+ repoRoot,
110
114
  },
111
- { kind: "samples", root: path.join(frameworkRoot.absPath, "samples") },
115
+ { kind: "samples", root: path.join(repoRoot, "samples"), repoRoot },
112
116
  ];
113
117
  }
114
- const appRoot = roots.find((r) => r.kind === "app" && isFlatSrcLayout(r));
115
- if (appRoot) {
116
- return [{ kind: "app", root: path.join(appRoot.absPath, "src") }];
118
+ const appRoots = roots.filter((r) => r.kind === "app" && isFlatSrcLayout(r));
119
+ if (appRoots.length > 0) {
120
+ return appRoots.map((r) => ({
121
+ kind: "app" as const,
122
+ root: path.join(r.absPath, "src"),
123
+ repoRoot: r.absPath,
124
+ }));
117
125
  }
118
- const otherRoots = roots.filter((r) => r.kind !== "framework" && !isFlatSrcLayout(r));
119
- return otherRoots.flatMap(sourceRootDirs).map((root) => ({ kind: "app" as const, root }));
120
- }
121
-
122
- function resolveRoot(roots: readonly RepoRoot[]): string {
123
- const frameworkRoot = roots.find((r) => r.kind === "framework");
124
- if (frameworkRoot) return frameworkRoot.absPath;
125
- const appRoot = roots.find((r) => r.kind === "app" && isFlatSrcLayout(r));
126
- if (appRoot) return appRoot.absPath;
127
- const otherRoots = roots.filter((r) => r.kind !== "framework" && !isFlatSrcLayout(r));
128
- return otherRoots[0]?.absPath ?? process.cwd();
126
+ return roots
127
+ .filter((r) => r.kind !== "framework" && !isFlatSrcLayout(r))
128
+ .flatMap((r) =>
129
+ sourceRootDirs(r).map((root) => ({ kind: "app" as const, root, repoRoot: r.absPath })),
130
+ );
129
131
  }
130
132
 
131
133
  type Violation = {
@@ -306,7 +308,6 @@ export const check: RepoCheck = {
306
308
  // Repo-Root aufgelöst") — the vacuity floor below reproduces that.
307
309
  return { violations: [], matchedFiles: 0, notApplicable: false };
308
310
  }
309
- const root = resolveRoot(roots);
310
311
  // Pre-filter count (every file walk() found, before the web/-segment
311
312
  // filter): an app repo whose screens are schema-driven with zero
312
313
  // web/*.tsx (phronexsis before its first screen) must not be flagged as
@@ -322,7 +323,7 @@ export const check: RepoCheck = {
322
323
  const webFiles = files.filter((f) => isWebFile(f, scope.root));
323
324
  for (const file of webFiles) {
324
325
  const target = scope.kind === "samples" ? warnings : violations;
325
- for (const v of checkFile(file, scope.kind, root)) {
326
+ for (const v of checkFile(file, scope.kind, scope.repoRoot)) {
326
327
  target.push({ file: v.file, line: v.line, message: violationMessage(v) });
327
328
  }
328
329
  }
@@ -101,17 +101,22 @@ export function baselineCounts(findings: readonly Finding[]): Record<string, num
101
101
  }
102
102
 
103
103
  const BASELINE_FILE = ".kumiko-raw-interactive-elements-baseline.json";
104
- const rawInteractiveElementsBaseline = baselineRatchet({
105
- file: path.join(ROOT, BASELINE_FILE),
106
- formatVersion: 1,
107
- unit: "raw interactive HTML finding(s)",
108
- });
104
+ const createBaseline = (baselineRoot: string) =>
105
+ baselineRatchet({
106
+ file: path.join(baselineRoot, BASELINE_FILE),
107
+ formatVersion: 1,
108
+ unit: "raw interactive HTML finding(s)",
109
+ });
109
110
 
110
111
  const REMEDIATION =
111
112
  "Framework replacement: <a> → usePrimitives().Link, <details>/<summary> → CollapsibleSection " +
112
113
  `(@cosmicdrift/kumiko-renderer-web). Genuine exception: // ${IGNORE_TAG} <reason>`;
113
114
 
114
- function analyse(files: readonly SourceFile[], compareBaseline: boolean): GuardOutcome {
115
+ function analyse(
116
+ files: readonly SourceFile[],
117
+ compareBaseline: boolean,
118
+ baselineRoot: string,
119
+ ): GuardOutcome {
115
120
  const findings = scan(files);
116
121
  if (!compareBaseline) {
117
122
  console.log(" Baseline comparison skipped (--no-baseline).");
@@ -119,7 +124,7 @@ function analyse(files: readonly SourceFile[], compareBaseline: boolean): GuardO
119
124
  }
120
125
  const resolveLine = (file: string): number => findings.find((f) => f.file === file)?.line ?? 1;
121
126
  return {
122
- violations: rawInteractiveElementsBaseline.check(baselineCounts(findings), REMEDIATION, {
127
+ violations: createBaseline(baselineRoot).check(baselineCounts(findings), REMEDIATION, {
123
128
  formatDriftRemediation:
124
129
  "Run `bun guards/guard-raw-interactive-elements.ts --write-baseline` once.",
125
130
  resolveLine,
@@ -127,12 +132,17 @@ function analyse(files: readonly SourceFile[], compareBaseline: boolean): GuardO
127
132
  };
128
133
  }
129
134
 
130
- export const guard: AstGuard = {
131
- name: "Raw-Interactive-Elements Guard (App-Repos)",
132
- scan: SCAN,
133
- hint: REMEDIATION,
134
- run: (files: readonly SourceFile[]) => analyse(files, true),
135
- };
135
+ export function createGuard(options: { readonly baselineRoot?: string } = {}): AstGuard {
136
+ const baselineRoot = options.baselineRoot ?? ROOT;
137
+ return {
138
+ name: "Raw-Interactive-Elements Guard (App-Repos)",
139
+ scan: SCAN,
140
+ hint: REMEDIATION,
141
+ run: (files: readonly SourceFile[]) => analyse(files, true, baselineRoot),
142
+ };
143
+ }
144
+
145
+ export const guard: AstGuard = createGuard();
136
146
 
137
147
  // Flags are read ONLY here, not in run() — the shared runner
138
148
  // (run-ui-guards.ts) runs every guard with the same argv, a
@@ -142,12 +152,12 @@ if (import.meta.main) {
142
152
  if (args.includes("--write-baseline")) {
143
153
  const project = buildSharedProject([guard]);
144
154
  const findings = scan(filesForGuard(project, guard));
145
- rawInteractiveElementsBaseline.write(baselineCounts(findings));
155
+ createBaseline(ROOT).write(baselineCounts(findings));
146
156
  process.exit(0);
147
157
  }
148
158
  if (args.includes("--no-baseline")) {
149
159
  const project = buildSharedProject([guard]);
150
- analyse(filesForGuard(project, guard), false);
160
+ analyse(filesForGuard(project, guard), false, ROOT);
151
161
  process.exit(0);
152
162
  }
153
163
  runStandalone(guard);
@@ -163,11 +163,12 @@ function countByFile(findings: readonly Finding[]): Record<string, number> {
163
163
  }
164
164
 
165
165
  const BASELINE_FILE = ".kumiko-tailwind-scan-surface-baseline.json";
166
- const tailwindScanSurfaceBaseline = baselineRatchet({
167
- file: path.join(BASELINE_ROOT, BASELINE_FILE),
168
- formatVersion: 1,
169
- unit: "class token(s)",
170
- });
166
+ const tailwindScanSurfaceBaseline = (baselineRoot: string) =>
167
+ baselineRatchet({
168
+ file: path.join(baselineRoot, BASELINE_FILE),
169
+ formatVersion: 1,
170
+ unit: "class token(s)",
171
+ });
171
172
 
172
173
  // Second, independent rule: a consuming app repo (studio,
173
174
  // publicstatus, …) imports Framework AND Enterprise packages by npm name
@@ -489,11 +490,12 @@ function reportPublishedScanSurfaceFindings(
489
490
  }
490
491
 
491
492
  const SOURCE_COVERAGE_BASELINE_FILE = ".kumiko-tailwind-source-coverage-baseline.json";
492
- const sourceCoverageBaseline = baselineRatchet({
493
- file: path.join(BASELINE_ROOT, SOURCE_COVERAGE_BASELINE_FILE),
494
- formatVersion: 1,
495
- unit: "missing @source entry/entries",
496
- });
493
+ const sourceCoverageBaseline = (baselineRoot: string) =>
494
+ baselineRatchet({
495
+ file: path.join(baselineRoot, SOURCE_COVERAGE_BASELINE_FILE),
496
+ formatVersion: 1,
497
+ unit: "missing @source entry/entries",
498
+ });
497
499
 
498
500
  export function sourceCoverageBaselineCounts(
499
501
  findings: readonly SourceCoverageFinding[],
@@ -503,9 +505,12 @@ export function sourceCoverageBaselineCounts(
503
505
  return counts;
504
506
  }
505
507
 
506
- function checkSourceCoverageBaseline(findings: readonly SourceCoverageFinding[]): GuardViolation[] {
508
+ function checkSourceCoverageBaseline(
509
+ findings: readonly SourceCoverageFinding[],
510
+ baselineRoot: string,
511
+ ): GuardViolation[] {
507
512
  const resolveLine = (file: string): number => findings.find((f) => f.file === file)?.line ?? 1;
508
- return sourceCoverageBaseline.check(
513
+ return sourceCoverageBaseline(baselineRoot).check(
509
514
  sourceCoverageBaselineCounts(findings),
510
515
  "Package delivers Tailwind classes without @source coverage in this app — add an @source entry for both install layouts (pattern: existing bundled-features entries).",
511
516
  {
@@ -522,9 +527,9 @@ export function baselineCounts(findings: readonly Finding[]): Record<string, num
522
527
  return countByFile(findings);
523
528
  }
524
529
 
525
- function checkBaseline(findings: readonly Finding[]): GuardViolation[] {
530
+ function checkBaseline(findings: readonly Finding[], baselineRoot: string): GuardViolation[] {
526
531
  const resolveLine = (file: string): number => findings.find((f) => f.file === file)?.line ?? 1;
527
- return tailwindScanSurfaceBaseline.check(
532
+ return tailwindScanSurfaceBaseline(baselineRoot).check(
528
533
  baselineCounts(findings),
529
534
  "Class outside the @source scan surface (renderer-web/src, renderer/src, samples/**/src) — reuse a class already emitted there, or move the styling into renderer-web.",
530
535
  {
@@ -539,6 +544,7 @@ export function analyse(
539
544
  files: readonly SourceFile[],
540
545
  compareBaseline: boolean,
541
546
  roots: readonly RepoRoot[] = resolveRepoRoots(),
547
+ baselineRoot: string = BASELINE_ROOT,
542
548
  ): GuardOutcome {
543
549
  const { findings } = scan(files, roots);
544
550
  const coverageFindings = scanSourceCoverage(roots);
@@ -550,23 +556,28 @@ export function analyse(
550
556
  }
551
557
  return {
552
558
  violations: [
553
- ...checkBaseline(findings),
554
- ...checkSourceCoverageBaseline(coverageFindings),
559
+ ...checkBaseline(findings, baselineRoot),
560
+ ...checkSourceCoverageBaseline(coverageFindings, baselineRoot),
555
561
  ...publishedScanSurfaceViolations(publishedSurfaceFindings),
556
562
  ],
557
563
  };
558
564
  }
559
565
 
560
- export const guard: AstGuard = {
561
- name: "Tailwind-Scan-Surface Guard",
562
- scan: SCAN,
563
- hint:
564
- "Tailwind class in bundled-features outside the @source scan surface (renderer-web/src, renderer/src, samples/**/src) " +
565
- `— reuse a class already emitted there, or move the styling into renderer-web. Justified exception: // ${IGNORE_TAG} <reason>. ` +
566
- "Package without @source coverage in an app: add an @source entry for both install layouts. " +
567
- "@source with a node_modules/<pkg>/<segment> path: check whether <pkg> even publishes that segment (package.json files).",
568
- run: (files, roots = resolveRepoRoots()) => analyse(files, true, roots),
569
- };
566
+ export function createGuard(options: { readonly baselineRoot?: string } = {}): AstGuard {
567
+ const baselineRoot = options.baselineRoot ?? BASELINE_ROOT;
568
+ return {
569
+ name: "Tailwind-Scan-Surface Guard",
570
+ scan: SCAN,
571
+ hint:
572
+ "Tailwind class in bundled-features outside the @source scan surface (renderer-web/src, renderer/src, samples/**/src) " +
573
+ `— reuse a class already emitted there, or move the styling into renderer-web. Justified exception: // ${IGNORE_TAG} <reason>. ` +
574
+ "Package without @source coverage in an app: add an @source entry for both install layouts. " +
575
+ "@source with a node_modules/<pkg>/<segment> path: check whether <pkg> even publishes that segment (package.json files).",
576
+ run: (files, roots = resolveRepoRoots()) => analyse(files, true, roots, baselineRoot),
577
+ };
578
+ }
579
+
580
+ export const guard: AstGuard = createGuard();
570
581
 
571
582
  // Flags are read ONLY here, not in run() — the shared runner
572
583
  // (run-ui-guards.ts) runs every guard with the same argv, a
@@ -576,8 +587,8 @@ if (import.meta.main) {
576
587
  if (args.includes("--write-baseline")) {
577
588
  const project = buildSharedProject([guard]);
578
589
  const { findings } = scan(filesForGuard(project, guard), resolveRepoRoots());
579
- tailwindScanSurfaceBaseline.write(baselineCounts(findings));
580
- sourceCoverageBaseline.write(
590
+ tailwindScanSurfaceBaseline(BASELINE_ROOT).write(baselineCounts(findings));
591
+ sourceCoverageBaseline(BASELINE_ROOT).write(
581
592
  sourceCoverageBaselineCounts(scanSourceCoverage(resolveRepoRoots())),
582
593
  );
583
594
  process.exit(0);
@@ -47,17 +47,16 @@ import * as path from "node:path";
47
47
  import { type Node, type SourceFile, SyntaxKind } from "ts-morph";
48
48
  import { type AstGuard, runStandalone, type ScanSpec } from "./_lib/guard-kit";
49
49
  import { literalStringOf, mentionsAsWord, nameForms } from "./_lib/handler-name-forms";
50
+ import { TEST_FILE_RE } from "./_lib/test-file";
50
51
 
51
52
  const ROOT = process.cwd();
52
53
 
53
54
  const SCAN: ScanSpec = {
54
- scope: "source",
55
+ scope: "source+tests",
55
56
  extensions: ["ts"],
56
57
  frameworkWithin: ["packages/*/src/**", "samples/**"],
57
58
  };
58
59
 
59
- const TEST_FILE = /\.test\.ts$/;
60
-
61
60
  // Quoted forms — a test "rejects this role" assertion carries the literal.
62
61
  const RESERVED_ROLE_LITERALS = ['"SystemAdmin"', '"system"', '"all"', '"anonymous"'];
63
62
 
@@ -81,7 +80,7 @@ function resolvedSchemaText(schemaProp: Node): string {
81
80
  export function findRoleInputHandlers(files: readonly SourceFile[]): RoleHandler[] {
82
81
  const out: RoleHandler[] = [];
83
82
  for (const sf of files) {
84
- if (TEST_FILE.test(sf.getFilePath())) continue;
83
+ if (TEST_FILE_RE.test(sf.getFilePath())) continue;
85
84
  for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
86
85
  if (call.getExpression().getText() !== "defineWriteHandler") continue;
87
86
  const arg = call.getArguments()[0];
@@ -105,7 +104,7 @@ export function findRoleInputHandlers(files: readonly SourceFile[]): RoleHandler
105
104
  function reservedRoleTestTexts(files: readonly SourceFile[]): string[] {
106
105
  const out: string[] = [];
107
106
  for (const sf of files) {
108
- if (!TEST_FILE.test(sf.getFilePath())) continue;
107
+ if (!TEST_FILE_RE.test(sf.getFilePath())) continue;
109
108
  const text = sf.getFullText();
110
109
  if (RESERVED_ROLE_LITERALS.some((r) => text.includes(r))) out.push(text);
111
110
  }
@@ -133,7 +132,7 @@ type OverrideHandler = { file: string; line: number };
133
132
  export function findOverrideHandlersMissingHelper(files: readonly SourceFile[]): OverrideHandler[] {
134
133
  const out: OverrideHandler[] = [];
135
134
  for (const sf of files) {
136
- if (TEST_FILE.test(sf.getFilePath())) continue;
135
+ if (TEST_FILE_RE.test(sf.getFilePath())) continue;
137
136
  for (const pa of sf.getDescendantsOfKind(SyntaxKind.PropertyAssignment)) {
138
137
  if (pa.getName() !== "tenantIdOverride") continue;
139
138
  if (pa.getInitializer()?.getText().startsWith("z.") !== true) continue;
@@ -181,7 +180,7 @@ function lineOfMatch(text: string, re: RegExp): number {
181
180
  export function findMembershipMintsMissingStrip(files: readonly SourceFile[]): MintSite[] {
182
181
  const out: MintSite[] = [];
183
182
  for (const sf of files) {
184
- if (TEST_FILE.test(sf.getFilePath())) continue;
183
+ if (TEST_FILE_RE.test(sf.getFilePath())) continue;
185
184
  const text = sf.getFullText();
186
185
  if (!SESSION_MINT.test(text)) continue;
187
186
  if (!MEMBERSHIP_SOURCE.test(text)) continue;
@@ -223,7 +222,7 @@ export function findGlobalUserWritesMissingMembershipCheck(
223
222
  ): GlobalUserWrite[] {
224
223
  const out: GlobalUserWrite[] = [];
225
224
  for (const sf of files) {
226
- if (TEST_FILE.test(sf.getFilePath())) continue;
225
+ if (TEST_FILE_RE.test(sf.getFilePath())) continue;
227
226
  for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
228
227
  if (call.getExpression().getText() !== "defineWriteHandler") continue;
229
228
  const arg = call.getArguments()[0];
@@ -17,7 +17,7 @@
17
17
  */
18
18
 
19
19
  import * as path from "node:path";
20
- import { Project, type SourceFile, SyntaxKind } from "ts-morph";
20
+ import { type Identifier, Node, Project, type SourceFile, SyntaxKind } from "ts-morph";
21
21
  import {
22
22
  type GuardViolation,
23
23
  type RepoCheck,
@@ -70,10 +70,23 @@ export interface Violation {
70
70
  forbiddenCalls: Array<{ name: string; line: number }>;
71
71
  }
72
72
 
73
+ // `import { setupTestStack as stack }` + `stack()` must resolve to the imported
74
+ // name, otherwise an alias hides both entrypoints and forbidden factories.
75
+ function importedNameOf(identifier: Identifier): string {
76
+ const localName = identifier.getText();
77
+ for (const imp of identifier.getSourceFile().getImportDeclarations()) {
78
+ for (const spec of imp.getNamedImports()) {
79
+ if ((spec.getAliasNode()?.getText() ?? spec.getName()) === localName) return spec.getName();
80
+ }
81
+ }
82
+ return localName;
83
+ }
84
+
73
85
  function collectCallNames(sf: SourceFile): Map<string, number[]> {
74
86
  const calls = new Map<string, number[]>();
75
87
  for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
76
- const name = call.getExpression().getText();
88
+ const callee = call.getExpression();
89
+ const name = Node.isIdentifier(callee) ? importedNameOf(callee) : callee.getText();
77
90
  const arr = calls.get(name) ?? [];
78
91
  arr.push(call.getStartLineNumber());
79
92
  calls.set(name, arr);
@@ -144,9 +144,6 @@ function scanFieldFactories(sf: SourceFile, roots: readonly RepoRoot[]): Finding
144
144
  const place = enclosingFieldName(call) ?? `${callee}(...)`;
145
145
  const line = call.getStartLineNumber();
146
146
  findings.push({ file, line, place, callee });
147
- console.warn(
148
- ` [text-field-stance WARN] ${file}:${line} ${callee}(...) at "${place}" has no personal stance — mark ${VALID_PERSONAL_HINT}`,
149
- );
150
147
  }
151
148
  return findings;
152
149
  }
@@ -212,6 +209,10 @@ function analyse(
212
209
  ): GuardOutcome {
213
210
  const { findings } = scan(files, roots);
214
211
  if (!compareBaseline) {
212
+ for (const f of findings)
213
+ console.warn(
214
+ ` [text-field-stance WARN] ${f.file}:${f.line} ${f.callee}(...) at "${f.place}" has no personal stance — mark ${VALID_PERSONAL_HINT}`,
215
+ );
215
216
  console.log(" Baseline comparison skipped (--no-baseline).");
216
217
  return { violations: [] };
217
218
  }
@@ -67,6 +67,8 @@ function isKnownReason(r: string): r is WrapperReason {
67
67
  // ── Built-in filter ─────────────────────────────────────────────────────
68
68
 
69
69
  const BUILTIN_NAMES = new Set([
70
+ "test",
71
+ "exec",
70
72
  "push",
71
73
  "pop",
72
74
  "shift",
@@ -300,9 +302,6 @@ function extractCalleeName(callNode: ReturnType<FnNode["getBody"]> | undefined):
300
302
  if (!expr) return null;
301
303
  if (expr.isKind(SyntaxKind.Identifier)) return expr.getText();
302
304
  if (expr.isKind(SyntaxKind.PropertyAccessExpression)) {
303
- // `/re/.test(x)` is a RegExp method call, not a call to a function named
304
- // "test" — a regex-literal receiver is never a wrapper callee.
305
- if (expr.getExpression().isKind(SyntaxKind.RegularExpressionLiteral)) return null;
306
305
  return expr.getName();
307
306
  }
308
307
  return null;