@metaobjectsdev/cli 0.23.0 → 0.23.1

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.
@@ -21,6 +21,23 @@
21
21
  // says NOTHING rather than reporting every name missing. Absence of evidence is
22
22
  // not evidence of absence, and a monorepo whose tests live outside `--cwd` must
23
23
  // not be told its requirements are unverified.
24
+ //
25
+ // WHAT COUNTS AS A TEST FILE IS THE PROJECT'S CALL, NOT OURS. The built-in patterns
26
+ // below are a convenience for the ecosystems this repo ports to, and they are a GUESS
27
+ // about someone else's repository. They were wrong on a mainstream case from the day
28
+ // they shipped: Maven Failsafe names integration tests `FooIT.java`, which matched
29
+ // nothing, so a JVM project naming a real integration test got a confident
30
+ // "the claim was never true".
31
+ //
32
+ // Two consequences, both deliberate:
33
+ // - `testFiles` (config: `verify.testFiles`) lets a project declare its own
34
+ // conventions, unioned with the built-ins. Nothing here can be authoritative
35
+ // about a convention we have never seen.
36
+ // - the fail-open above is extended from "no test files at all" to the case that
37
+ // actually bites: a name we cannot find in the corpus, which IS present in a file
38
+ // the corpus definition did not classify. That is our ignorance, not a broken
39
+ // claim, and it is reported as such (WARN_REQUIREMENT_TEST_UNCLASSIFIED) rather
40
+ // than as an error. The error is reserved for a name that appears NOWHERE.
24
41
 
25
42
  import { readdirSync, readFileSync, statSync } from "node:fs";
26
43
  import { join, relative, sep } from "node:path";
@@ -32,6 +49,8 @@ import {
32
49
 
33
50
  export const ERR_REQUIREMENT_TEST_MISSING = "ERR_REQUIREMENT_TEST_MISSING";
34
51
  export const WARN_REQUIREMENT_TEST_SKIPPED = "WARN_REQUIREMENT_TEST_SKIPPED";
52
+ export const WARN_REQUIREMENT_TEST_COMMENT_ONLY = "WARN_REQUIREMENT_TEST_COMMENT_ONLY";
53
+ export const WARN_REQUIREMENT_TEST_UNCLASSIFIED = "WARN_REQUIREMENT_TEST_UNCLASSIFIED";
35
54
 
36
55
  export interface VerifiedByDiagnostic {
37
56
  severity: "error" | "warn";
@@ -45,19 +64,59 @@ const IGNORE_SEGMENTS = new Set([
45
64
  ".metaobjects", "generated", "target", "bin", "obj", "__pycache__", ".venv", "venv",
46
65
  ]);
47
66
 
48
- /** Test files across the five ecosystems this project ports to. */
67
+ /**
68
+ * Test files across the five ecosystems this project ports to — a CONVENIENCE DEFAULT,
69
+ * never an authority. A project whose conventions differ declares them via
70
+ * `verify.testFiles`; see the module header.
71
+ *
72
+ * The `IT` entries are Maven Failsafe's own defaults (`IT*`, `*IT`, `*ITCase`), which
73
+ * is how every JVM project in the wild names an integration test. Their absence is the
74
+ * bug that motivated making this list extensible in the first place.
75
+ */
49
76
  const TEST_FILE = new RegExp(
50
77
  [
51
78
  "\\.(?:test|spec)\\.[cm]?[jt]sx?$", // bun / jest / vitest / mocha
52
79
  "(?:^|[./_-])[Tt]est[^/]*\\.java$", // JUnit — TestFoo.java
53
80
  "[A-Za-z0-9]Test(?:s)?\\.java$", // JUnit — FooTest.java / FooTests.java
81
+ "[A-Za-z0-9]IT(?:Case)?\\.java$", // Failsafe — FooIT.java / FooITCase.java
82
+ "^IT[A-Za-z0-9][^/]*\\.java$", // Failsafe — ITFoo.java
54
83
  "[A-Za-z0-9]Tests?\\.cs$", // xUnit / NUnit
55
84
  "^test_[^/]*\\.py$", // pytest
56
85
  "[^/]*_test\\.py$", // pytest, trailing convention
57
86
  "[A-Za-z0-9]Test(?:s)?\\.kt$", // Kotlin
87
+ "[A-Za-z0-9]IT(?:Case)?\\.kt$", // Failsafe under Kotlin — FooIT.kt
58
88
  ].join("|"),
59
89
  );
60
90
 
91
+ /** Files worth searching when a name is missing from the corpus, to tell "nowhere" from
92
+ * "somewhere I did not classify". Source-ish only; a match in a lockfile proves nothing. */
93
+ const SOURCE_FILE = /\.(?:[cm]?[jt]sx?|java|kt|kts|cs|py|rb|go|rs|scala|groovy|feature)$/;
94
+
95
+ /**
96
+ * A glob as permissive as the ones adopters actually write (`**​/*IT.kt`, `*.feature`),
97
+ * anchored at the project root and matched against forward-slash relative paths.
98
+ *
99
+ * Deliberately small: `**` spans separators, `*` does not, `?` is one non-separator
100
+ * character. Anything richer belongs to a glob library, and pulling one in for a config
101
+ * knob this narrow is not worth the dependency.
102
+ */
103
+ function globToRegExp(glob: string): RegExp {
104
+ let out = "";
105
+ for (let i = 0; i < glob.length; i++) {
106
+ const c = glob[i]!;
107
+ if (c === "*") {
108
+ if (glob[i + 1] === "*") {
109
+ // `**/` may match zero segments, so `**/*.feature` matches a root-level file.
110
+ if (glob[i + 2] === "/") { out += "(?:.*/)?"; i += 2; } else { out += ".*"; i += 1; }
111
+ } else out += "[^/]*";
112
+ continue;
113
+ }
114
+ if (c === "?") { out += "[^/]"; continue; }
115
+ out += c.replace(/[.+^${}()|[\]\\]/g, "\\$&");
116
+ }
117
+ return new RegExp(`^${out}$`);
118
+ }
119
+
61
120
  /** Markers that a test exists but is disabled, across the same ecosystems. */
62
121
  const SKIP_MARKER = new RegExp(
63
122
  [
@@ -75,9 +134,18 @@ interface TestCorpus {
75
134
  files: number;
76
135
  /** rel path -> lines, kept so a skip marker can be located near the name. */
77
136
  byFile: Map<string, string[]>;
137
+ /** Source files NOT classified as tests, kept only to tell a broken claim from an
138
+ * unknown convention. Paths only — contents are read on demand, on the error path. */
139
+ unclassified: string[];
78
140
  }
79
141
 
80
- function walk(dir: string, root: string, acc: TestCorpus, depth = 0): void {
142
+ function walk(
143
+ dir: string,
144
+ root: string,
145
+ acc: TestCorpus,
146
+ isTestFile: (rel: string, base: string) => boolean,
147
+ depth = 0,
148
+ ): void {
81
149
  if (depth > 12) return; // pathological trees; the scan is advisory, not exhaustive
82
150
  let entries;
83
151
  try {
@@ -88,14 +156,19 @@ function walk(dir: string, root: string, acc: TestCorpus, depth = 0): void {
88
156
  for (const e of entries) {
89
157
  if (e.isDirectory()) {
90
158
  if (IGNORE_SEGMENTS.has(e.name) || e.name.startsWith(".")) continue;
91
- walk(join(dir, e.name), root, acc, depth + 1);
159
+ walk(join(dir, e.name), root, acc, isTestFile, depth + 1);
92
160
  continue;
93
161
  }
94
- if (!e.isFile() || !TEST_FILE.test(e.name)) continue;
162
+ if (!e.isFile()) continue;
95
163
  const abs = join(dir, e.name);
164
+ const rel = relative(root, abs).split(sep).join("/");
165
+ if (!isTestFile(rel, e.name)) {
166
+ if (SOURCE_FILE.test(e.name) && acc.unclassified.length < 20_000) acc.unclassified.push(rel);
167
+ continue;
168
+ }
96
169
  try {
97
170
  if (statSync(abs).size > 512 * 1024) continue;
98
- acc.byFile.set(relative(root, abs).split(sep).join("/"), readFileSync(abs, "utf8").split("\n"));
171
+ acc.byFile.set(rel, readFileSync(abs, "utf8").split("\n"));
99
172
  acc.files++;
100
173
  } catch {
101
174
  /* unreadable file is not a finding */
@@ -103,6 +176,45 @@ function walk(dir: string, root: string, acc: TestCorpus, depth = 0): void {
103
176
  }
104
177
  }
105
178
 
179
+ /** Does this path or body look like a test the corpus definition simply did not match?
180
+ * Deliberately narrow: living under a test directory, or containing an assertion/test
181
+ * declaration. Without this, a name occurring anywhere in PRODUCTION source downgrades a
182
+ * genuinely broken claim to a warning — the exact failure the comment-only check exists
183
+ * to catch. */
184
+ const TESTISH_PATH = /(^|\/)(tests?|spec|__tests__|src\/test)(\/|$)/i;
185
+ const TESTISH_BODY = /\b(assert\w*|expect|should|@Test|def test_|it\(|test\(|describe\()/;
186
+
187
+ /** Test by LOCATION or by CONTENT — either is enough. Production source with a matching
188
+ * name satisfies neither, which is the case that must stay a hard error. */
189
+ function looksLikeTest(rel: string, lines: string[]): boolean {
190
+ return TESTISH_PATH.test(rel) || lines.some((l) => TESTISH_BODY.test(l));
191
+ }
192
+
193
+ /**
194
+ * Where does this name live, if not in the test corpus?
195
+ *
196
+ * Only ever called on the miss path, so the cost is paid per BROKEN claim rather than
197
+ * per run. Returns the first unclassified source file containing the name, which is
198
+ * enough to tell the author which pattern they are missing.
199
+ */
200
+ function findOutsideCorpus(name: string, root: string, files: string[]): string | undefined {
201
+ const rx = wordRx(name);
202
+ for (const rel of files) {
203
+ try {
204
+ const abs = join(root, ...rel.split("/"));
205
+ if (statSync(abs).size > 512 * 1024) continue;
206
+ const lines = readFileSync(abs, "utf8").split("\n");
207
+ for (let i = 0; i < lines.length; i++) {
208
+ const line = lines[i] ?? "";
209
+ if (rx.test(line) && !isCommentLine(line, rel) && looksLikeTest(rel, lines)) return rel;
210
+ }
211
+ } catch {
212
+ /* unreadable file is not a finding */
213
+ }
214
+ }
215
+ return undefined;
216
+ }
217
+
106
218
  /** Every `requirement.*` node in the tree, at any nesting depth. */
107
219
  function collect(root: MetaData): MetaRequirement[] {
108
220
  const out: MetaRequirement[] = [];
@@ -124,6 +236,23 @@ function collect(root: MetaData): MetaRequirement[] {
124
236
  * emit the confident false error this scan is built to avoid. Camel-case boundaries
125
237
  * stay strict, which is what actually prevents a short name matching a longer one.
126
238
  */
239
+ /**
240
+ * Is this whole line a comment?
241
+ *
242
+ * WHOLE-LINE ONLY, deliberately. Stripping from the first `//` would truncate a code
243
+ * line containing one inside a string — a test titled with a URL is the obvious case —
244
+ * and turn a real match into a confident false error, which is the failure this scan is
245
+ * built to avoid. A trailing comment after code therefore still counts as code; that
246
+ * under-flags, which is the repo's standing bias for drift checks.
247
+ *
248
+ * `#` is Python-only: in TypeScript it opens a private field, not a comment.
249
+ */
250
+ function isCommentLine(line: string, file: string): boolean {
251
+ const t = line.trimStart();
252
+ if (t.startsWith("//") || t.startsWith("/*") || t.startsWith("*")) return true;
253
+ return file.endsWith(".py") && t.startsWith("#");
254
+ }
255
+
127
256
  function wordRx(name: string): RegExp {
128
257
  return new RegExp(`(?:^|[^A-Za-z0-9])${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?![A-Za-z0-9])`);
129
258
  }
@@ -135,12 +264,23 @@ function wordRx(name: string): RegExp {
135
264
  * and silent on `abandoned`/`superseded`, because a retired requirement naming a
136
265
  * deleted test is the entry doing its job, not drift.
137
266
  */
138
- export function checkVerifiedBy(root: MetaData, cwd: string): VerifiedByDiagnostic[] {
267
+ export function checkVerifiedBy(
268
+ root: MetaData,
269
+ cwd: string,
270
+ testFiles?: string[],
271
+ ): VerifiedByDiagnostic[] {
139
272
  const reqs = collect(root).filter((r) => r.verifiedBy().length > 0);
140
273
  if (reqs.length === 0) return []; // opt-in by declaration
141
274
 
142
- const corpus: TestCorpus = { files: 0, byFile: new Map() };
143
- walk(cwd, cwd, corpus);
275
+ // Project-declared conventions ADD to the built-ins: the failure being fixed is
276
+ // under-matching, and a project that names an extra convention is telling us
277
+ // something we did not know — not asking us to forget what we did.
278
+ const declared = (testFiles ?? []).map(globToRegExp);
279
+ const isTestFile = (rel: string, base: string): boolean =>
280
+ TEST_FILE.test(base) || declared.some((rx) => rx.test(rel));
281
+
282
+ const corpus: TestCorpus = { files: 0, byFile: new Map(), unclassified: [] };
283
+ walk(cwd, cwd, corpus, isTestFile);
144
284
  if (corpus.files === 0) return []; // fail open: nothing to judge against
145
285
 
146
286
  const out: VerifiedByDiagnostic[] = [];
@@ -149,9 +289,19 @@ export function checkVerifiedBy(root: MetaData, cwd: string): VerifiedByDiagnost
149
289
  const rx = wordRx(test);
150
290
  let foundIn: string | undefined;
151
291
  let skippedAt: string | undefined;
292
+ // #293-adjacent (the `verifiedBy` audit): a name that occurs ONLY in comments
293
+ // satisfied this scan, because the match is line-agnostic. That is how a claim
294
+ // came to name `mountCrudRoutes`, whose single occurrence in an entire corpus was
295
+ // inside a `// via mountCrudRoutes(...)` note. Tracked separately so the name can
296
+ // still be reported as found (it is) while saying what it was found in.
297
+ let commentOnlyAt: string | undefined;
152
298
  for (const [file, lines] of corpus.byFile) {
153
299
  for (let i = 0; i < lines.length; i++) {
154
300
  if (!rx.test(lines[i] ?? "")) continue;
301
+ if (isCommentLine(lines[i] ?? "", file)) {
302
+ commentOnlyAt ??= `${file}:${i + 1}`;
303
+ continue;
304
+ }
155
305
  foundIn ??= file;
156
306
  // a decorator/annotation sits above the declaration it disables
157
307
  const window = lines.slice(Math.max(0, i - 3), i + 1).join("\n");
@@ -160,17 +310,52 @@ export function checkVerifiedBy(root: MetaData, cwd: string): VerifiedByDiagnost
160
310
  if (foundIn !== undefined && skippedAt !== undefined) break;
161
311
  }
162
312
 
313
+ // Found, but only ever in prose. Not an error — the scan's job is to catch a name
314
+ // that has gone missing, and this one has not — but a comment proves nothing, so
315
+ // the claim is reported rather than silently accepted.
316
+ if (foundIn === undefined && commentOnlyAt !== undefined) {
317
+ out.push({
318
+ severity: "warn",
319
+ code: WARN_REQUIREMENT_TEST_COMMENT_ONLY,
320
+ name: req.name,
321
+ message:
322
+ `'verifiedBy' names '${test}', which appears only in a comment (${commentOnlyAt}) ` +
323
+ `and in no test declaration. A comment proves nothing — name the test that asserts it.`,
324
+ });
325
+ continue;
326
+ }
327
+
163
328
  if (foundIn === undefined) {
164
329
  if (req.requiresLiveNodes()) {
165
- out.push({
166
- severity: "error",
167
- code: ERR_REQUIREMENT_TEST_MISSING,
168
- name: req.name,
169
- message:
170
- `'verifiedBy' names '${test}', which appears in none of the ` +
171
- `${corpus.files} test file(s) found under this project. Either the test was ` +
172
- `renamed or removed, or the claim was never true.`,
173
- });
330
+ // Before calling a claim broken, rule out the likelier explanation: that this
331
+ // project names its tests in a way the corpus definition does not know. A name
332
+ // sitting in an unclassified source file is OUR ignorance, and saying "the claim
333
+ // was never true" about it is the tool being confidently wrong.
334
+ const elsewhere = findOutsideCorpus(test, cwd, corpus.unclassified);
335
+ out.push(
336
+ elsewhere !== undefined
337
+ ? {
338
+ severity: "warn",
339
+ code: WARN_REQUIREMENT_TEST_UNCLASSIFIED,
340
+ name: req.name,
341
+ message:
342
+ `'verifiedBy' names '${test}', which is not in any of the ${corpus.files} ` +
343
+ `file(s) recognised as tests, but DOES appear in ${elsewhere}. That file is ` +
344
+ `probably a test this scan does not know how to recognise — declare the ` +
345
+ `convention in metaobjects.config.ts (verify.testFiles, e.g. ` +
346
+ `["**/*IT.kt"]) and this becomes a real check instead of a guess.`,
347
+ }
348
+ : {
349
+ severity: "error",
350
+ code: ERR_REQUIREMENT_TEST_MISSING,
351
+ name: req.name,
352
+ message:
353
+ `'verifiedBy' names '${test}', which appears in none of the ` +
354
+ `${corpus.files} test file(s) found under this project, and in no other ` +
355
+ `source file either. Either the test was renamed or removed, or the ` +
356
+ `claim was never true.`,
357
+ },
358
+ );
174
359
  }
175
360
  continue;
176
361
  }