@metaobjectsdev/cli 0.21.6 → 0.22.0-rc.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.
@@ -0,0 +1,190 @@
1
+ // `meta verify` — the `@verifiedBy` check.
2
+ //
3
+ // A requirement's `@verifiedBy` names the tests that prove the behaviour. verify
4
+ // checks each name EXISTS and is NOT SKIPPED; it never runs them. Running them is
5
+ // the test runner's job, and a requirement gate that shelled out to one would be
6
+ // slow, ecosystem-specific, and wrong in CI.
7
+ //
8
+ // Until this existed, `@verifiedBy` was registered vocabulary that nothing read —
9
+ // the precise pattern ADR-0007 Amendment 2 and the `@role` shrink exist to punish.
10
+ // An attribute the loader accepts and no consumer dispatches on teaches authors
11
+ // that declaring is free and means nothing.
12
+ //
13
+ // PRECISION OVER RECALL, deliberately. The scan matches a name anywhere in the
14
+ // test corpus, as a whole word, in any language. That is the most generous
15
+ // possible reading, so a "missing" verdict means the name appears in NO test file
16
+ // at all — which is a broken claim in any ecosystem. The repo's standing rule for
17
+ // drift checks is to bias toward under-flagging, and a nagging gate gets disabled,
18
+ // which costs more than the misses.
19
+ //
20
+ // FAIL-OPEN ON INABILITY. If the project has no test files this scan can see, it
21
+ // says NOTHING rather than reporting every name missing. Absence of evidence is
22
+ // not evidence of absence, and a monorepo whose tests live outside `--cwd` must
23
+ // not be told its requirements are unverified.
24
+
25
+ import { readdirSync, readFileSync, statSync } from "node:fs";
26
+ import { join, relative, sep } from "node:path";
27
+ import {
28
+ TYPE_REQUIREMENT,
29
+ type MetaData,
30
+ type MetaRequirement,
31
+ } from "@metaobjectsdev/metadata";
32
+
33
+ export const ERR_REQUIREMENT_TEST_MISSING = "ERR_REQUIREMENT_TEST_MISSING";
34
+ export const WARN_REQUIREMENT_TEST_SKIPPED = "WARN_REQUIREMENT_TEST_SKIPPED";
35
+
36
+ export interface VerifiedByDiagnostic {
37
+ severity: "error" | "warn";
38
+ code: string;
39
+ name?: string;
40
+ message: string;
41
+ }
42
+
43
+ const IGNORE_SEGMENTS = new Set([
44
+ "node_modules", ".git", "dist", "build", "out", ".next", "coverage",
45
+ ".metaobjects", "generated", "target", "bin", "obj", "__pycache__", ".venv", "venv",
46
+ ]);
47
+
48
+ /** Test files across the five ecosystems this project ports to. */
49
+ const TEST_FILE = new RegExp(
50
+ [
51
+ "\\.(?:test|spec)\\.[cm]?[jt]sx?$", // bun / jest / vitest / mocha
52
+ "(?:^|[./_-])[Tt]est[^/]*\\.java$", // JUnit — TestFoo.java
53
+ "[A-Za-z0-9]Test(?:s)?\\.java$", // JUnit — FooTest.java / FooTests.java
54
+ "[A-Za-z0-9]Tests?\\.cs$", // xUnit / NUnit
55
+ "^test_[^/]*\\.py$", // pytest
56
+ "[^/]*_test\\.py$", // pytest, trailing convention
57
+ "[A-Za-z0-9]Test(?:s)?\\.kt$", // Kotlin
58
+ ].join("|"),
59
+ );
60
+
61
+ /** Markers that a test exists but is disabled, across the same ecosystems. */
62
+ const SKIP_MARKER = new RegExp(
63
+ [
64
+ "\\b(?:it|test|describe)\\.(?:skip|todo)\\b", // jest/vitest/bun
65
+ "\\bx(?:it|test|describe)\\b", // mocha/jasmine
66
+ "@Disabled\\b", // JUnit 5
67
+ "@Ignore\\b", // JUnit 4 / Kotlin
68
+ "@pytest\\.mark\\.skip", // pytest
69
+ "\\[Ignore[\\](]", // MSTest / NUnit
70
+ "\\bSkip\\s*=", // xUnit [Fact(Skip = "...")]
71
+ ].join("|"),
72
+ );
73
+
74
+ interface TestCorpus {
75
+ files: number;
76
+ /** rel path -> lines, kept so a skip marker can be located near the name. */
77
+ byFile: Map<string, string[]>;
78
+ }
79
+
80
+ function walk(dir: string, root: string, acc: TestCorpus, depth = 0): void {
81
+ if (depth > 12) return; // pathological trees; the scan is advisory, not exhaustive
82
+ let entries;
83
+ try {
84
+ entries = readdirSync(dir, { withFileTypes: true });
85
+ } catch {
86
+ return;
87
+ }
88
+ for (const e of entries) {
89
+ if (e.isDirectory()) {
90
+ if (IGNORE_SEGMENTS.has(e.name) || e.name.startsWith(".")) continue;
91
+ walk(join(dir, e.name), root, acc, depth + 1);
92
+ continue;
93
+ }
94
+ if (!e.isFile() || !TEST_FILE.test(e.name)) continue;
95
+ const abs = join(dir, e.name);
96
+ try {
97
+ if (statSync(abs).size > 512 * 1024) continue;
98
+ acc.byFile.set(relative(root, abs).split(sep).join("/"), readFileSync(abs, "utf8").split("\n"));
99
+ acc.files++;
100
+ } catch {
101
+ /* unreadable file is not a finding */
102
+ }
103
+ }
104
+ }
105
+
106
+ /** Every `requirement.*` node in the tree, at any nesting depth. */
107
+ function collect(root: MetaData): MetaRequirement[] {
108
+ const out: MetaRequirement[] = [];
109
+ const rec = (n: MetaData): void => {
110
+ for (const c of n.children()) {
111
+ if (c.type === TYPE_REQUIREMENT) out.push(c as MetaRequirement);
112
+ rec(c);
113
+ }
114
+ };
115
+ rec(root);
116
+ return out;
117
+ }
118
+
119
+ /**
120
+ * A whole-word match, so `OrderServiceTest` never satisfies a claim naming `Order`.
121
+ *
122
+ * `_` counts as a SEPARATOR, not a word character: pytest's `def test_OrderServiceTest`
123
+ * plainly is the test a claim naming `OrderServiceTest` means, and refusing it would
124
+ * emit the confident false error this scan is built to avoid. Camel-case boundaries
125
+ * stay strict, which is what actually prevents a short name matching a longer one.
126
+ */
127
+ function wordRx(name: string): RegExp {
128
+ return new RegExp(`(?:^|[^A-Za-z0-9])${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?![A-Za-z0-9])`);
129
+ }
130
+
131
+ /**
132
+ * Check every `@verifiedBy` name against the project's test corpus.
133
+ *
134
+ * Severity mirrors `@implementedBy`: a broken claim is an ERROR on `live`/`partial`
135
+ * and silent on `abandoned`/`superseded`, because a retired requirement naming a
136
+ * deleted test is the entry doing its job, not drift.
137
+ */
138
+ export function checkVerifiedBy(root: MetaData, cwd: string): VerifiedByDiagnostic[] {
139
+ const reqs = collect(root).filter((r) => r.verifiedBy().length > 0);
140
+ if (reqs.length === 0) return []; // opt-in by declaration
141
+
142
+ const corpus: TestCorpus = { files: 0, byFile: new Map() };
143
+ walk(cwd, cwd, corpus);
144
+ if (corpus.files === 0) return []; // fail open: nothing to judge against
145
+
146
+ const out: VerifiedByDiagnostic[] = [];
147
+ for (const req of reqs) {
148
+ for (const test of req.verifiedBy()) {
149
+ const rx = wordRx(test);
150
+ let foundIn: string | undefined;
151
+ let skippedAt: string | undefined;
152
+ for (const [file, lines] of corpus.byFile) {
153
+ for (let i = 0; i < lines.length; i++) {
154
+ if (!rx.test(lines[i] ?? "")) continue;
155
+ foundIn ??= file;
156
+ // a decorator/annotation sits above the declaration it disables
157
+ const window = lines.slice(Math.max(0, i - 3), i + 1).join("\n");
158
+ if (SKIP_MARKER.test(window)) skippedAt ??= `${file}:${i + 1}`;
159
+ }
160
+ if (foundIn !== undefined && skippedAt !== undefined) break;
161
+ }
162
+
163
+ if (foundIn === undefined) {
164
+ 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
+ });
174
+ }
175
+ continue;
176
+ }
177
+ if (skippedAt !== undefined) {
178
+ out.push({
179
+ severity: "warn",
180
+ code: WARN_REQUIREMENT_TEST_SKIPPED,
181
+ name: req.name,
182
+ message:
183
+ `'verifiedBy' names '${test}', but it is disabled at ${skippedAt}. ` +
184
+ `A skipped test proves nothing — the requirement reads as verified and is not.`,
185
+ });
186
+ }
187
+ }
188
+ }
189
+ return out;
190
+ }