@metaobjectsdev/cli 0.21.6 → 0.22.0-rc.2
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/dist/src/commands/verify.d.ts.map +1 -1
- package/dist/src/commands/verify.js +30 -1
- package/dist/src/commands/verify.js.map +1 -1
- package/dist/src/lib/detect-stack.d.ts +3 -1
- package/dist/src/lib/detect-stack.d.ts.map +1 -1
- package/dist/src/lib/detect-stack.js +50 -5
- package/dist/src/lib/detect-stack.js.map +1 -1
- package/dist/src/lib/requirement-check.d.ts +63 -0
- package/dist/src/lib/requirement-check.d.ts.map +1 -0
- package/dist/src/lib/requirement-check.js +234 -0
- package/dist/src/lib/requirement-check.js.map +1 -0
- package/dist/src/lib/verified-by-scan.d.ts +18 -0
- package/dist/src/lib/verified-by-scan.d.ts.map +1 -0
- package/dist/src/lib/verified-by-scan.js +168 -0
- package/dist/src/lib/verified-by-scan.js.map +1 -0
- package/package.json +10 -10
- package/src/commands/verify.ts +28 -1
- package/src/lib/detect-stack.ts +48 -5
- package/src/lib/requirement-check.ts +272 -0
- package/src/lib/verified-by-scan.ts +190 -0
|
@@ -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
|
+
}
|