@abseed/spectra-drift-check 0.1.0 → 0.3.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/LICENSE +1 -1
- package/NOTICE +1 -1
- package/dist/check.d.ts +4 -3
- package/dist/check.js +45 -8
- package/dist/implements.d.ts +15 -0
- package/dist/implements.js +56 -19
- package/dist/index.d.ts +8 -4
- package/dist/index.js +7 -3
- package/package.json +12 -17
package/LICENSE
CHANGED
|
@@ -187,7 +187,7 @@
|
|
|
187
187
|
same "printed page" as the copyright notice for easier
|
|
188
188
|
identification within third-party archives.
|
|
189
189
|
|
|
190
|
-
Copyright
|
|
190
|
+
Copyright 2026 Luke Pezet
|
|
191
191
|
|
|
192
192
|
Licensed under the Apache License, Version 2.0 (the "License");
|
|
193
193
|
you may not use this file except in compliance with the License.
|
package/NOTICE
CHANGED
package/dist/check.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import type { Marker, Snapshot } from './implements.js';
|
|
2
|
-
export type DriftFindingKind = 'no-snapshot' | 'no-markers' | 'malformed-marker' | 'unknown-term' | 'unimplemented-term';
|
|
1
|
+
import type { Marker, Snapshot, VerifyMarker } from './implements.js';
|
|
2
|
+
export type DriftFindingKind = 'no-snapshot' | 'no-markers' | 'malformed-marker' | 'unknown-term' | 'unimplemented-term' | 'unknown-expectation' | 'unverified-expectation';
|
|
3
3
|
export interface DriftFinding {
|
|
4
4
|
kind: DriftFindingKind;
|
|
5
5
|
message: string;
|
|
@@ -9,10 +9,11 @@ export interface DriftFinding {
|
|
|
9
9
|
* file, not a missing service, so it fails hard rather than skipping (a green run that checked
|
|
10
10
|
* nothing is the outcome worth refusing).
|
|
11
11
|
*/
|
|
12
|
-
export declare function checkDrift(markers: Marker[], snapshot: Snapshot | null): DriftFinding[];
|
|
12
|
+
export declare function checkDrift(markers: Marker[], snapshot: Snapshot | null, verifyMarkers?: VerifyMarker[]): DriftFinding[];
|
|
13
13
|
/**
|
|
14
14
|
* Read markers from `srcDir` and the snapshot at `snapshotPath`, and run the check. The convenience
|
|
15
15
|
* a consumer project's test calls: `const { ok, findings } = driftCheck({ srcDir, snapshotPath })`.
|
|
16
|
+
* `// verifies:` markers are read from the same tree (including its test files).
|
|
16
17
|
*/
|
|
17
18
|
export declare function driftCheck(opts: {
|
|
18
19
|
srcDir: string;
|
package/dist/check.js
CHANGED
|
@@ -5,14 +5,23 @@
|
|
|
5
5
|
* consumer project turns it into a one-line test in whatever runner it already uses (see the README).
|
|
6
6
|
* Empty list means the code and the committed snapshot are in sync.
|
|
7
7
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
8
|
+
* It checks two correspondences, symmetrically:
|
|
9
|
+
*
|
|
10
|
+
* - **Terms ↔ code.** A malformed `implements:` marker, a marker naming a term the glossary no longer
|
|
11
|
+
* has, and a term (entity/function/event) nothing implements.
|
|
12
|
+
* - **Expectations ↔ tests (GH #96).** A malformed `verifies:` marker, a marker naming an expectation
|
|
13
|
+
* the glossary no longer has, and a *functional* expectation no test verifies. Non-functional
|
|
14
|
+
* expectations are exempt — they are properties of a running build, checked by driving it, not by a
|
|
15
|
+
* test phrased in glossary vocabulary.
|
|
16
|
+
*
|
|
17
|
+
* What it deliberately cannot catch: a term whose *spec was rewritten*, or an expectation whose
|
|
18
|
+
* *wording changed* — the marker still names it and still looks right. That is what the per-term and
|
|
19
|
+
* per-expectation `hash` in the snapshot is for: refreshing the file makes `git diff
|
|
20
|
+
* specs.snapshot.json` name what moved, so the check fails loud for structure and review catches the
|
|
21
|
+
* rest.
|
|
13
22
|
*/
|
|
14
23
|
import { existsSync } from 'node:fs';
|
|
15
|
-
import { implementersOf, readMarkers, readSnapshot } from './implements.js';
|
|
24
|
+
import { implementersOf, readMarkers, readSnapshot, readVerifyMarkers } from './implements.js';
|
|
16
25
|
/** Terms that must be implemented by code. attribute-types are value shapes carried by other terms. */
|
|
17
26
|
const NEEDS_IMPLEMENTING = new Set(['entity', 'function', 'event']);
|
|
18
27
|
/**
|
|
@@ -20,7 +29,7 @@ const NEEDS_IMPLEMENTING = new Set(['entity', 'function', 'event']);
|
|
|
20
29
|
* file, not a missing service, so it fails hard rather than skipping (a green run that checked
|
|
21
30
|
* nothing is the outcome worth refusing).
|
|
22
31
|
*/
|
|
23
|
-
export function checkDrift(markers, snapshot) {
|
|
32
|
+
export function checkDrift(markers, snapshot, verifyMarkers = []) {
|
|
24
33
|
if (!snapshot) {
|
|
25
34
|
return [{ kind: 'no-snapshot', message: 'No specs.snapshot.json — run export_specs and commit the result.' }];
|
|
26
35
|
}
|
|
@@ -50,15 +59,43 @@ export function checkDrift(markers, snapshot) {
|
|
|
50
59
|
findings.push({ kind: 'unimplemented-term', message: `${term.name} (${term.type}) — nothing implements it; an implementation pass is due` });
|
|
51
60
|
}
|
|
52
61
|
}
|
|
62
|
+
// Expectations ↔ tests. A snapshot exported before expectations existed has none, so this is a
|
|
63
|
+
// no-op there; the field is optional for exactly that reason.
|
|
64
|
+
const expectations = snapshot.expectations ?? [];
|
|
65
|
+
const knownExpectations = new Set(expectations.map((expectation) => expectation.id));
|
|
66
|
+
for (const marker of verifyMarkers) {
|
|
67
|
+
if (marker.malformed.length > 0) {
|
|
68
|
+
findings.push({
|
|
69
|
+
kind: 'malformed-marker',
|
|
70
|
+
message: `${marker.file}:${marker.line} — not expectation ids: ${marker.malformed.join(', ')} (a verifies: marker names ids like e-001; put prose on the next line)`,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
for (const id of marker.ids) {
|
|
74
|
+
if (!knownExpectations.has(id)) {
|
|
75
|
+
findings.push({ kind: 'unknown-expectation', message: `${marker.file}:${marker.line} — verifies "${id}", not a live expectation in the glossary (renamed, retired, or a typo)` });
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
const verified = new Set(verifyMarkers.flatMap((marker) => marker.ids));
|
|
80
|
+
for (const expectation of expectations) {
|
|
81
|
+
// Only functional expectations become an example test that a // verifies: marker names. A
|
|
82
|
+
// non-functional one is a property of a build; an invariant verifies as a property over all
|
|
83
|
+
// states (the verify-loop's job, not a single marker) — so both are exempt from this check.
|
|
84
|
+
if (expectation.kind === 'functional' && !verified.has(expectation.id)) {
|
|
85
|
+
findings.push({ kind: 'unverified-expectation', message: `${expectation.id} (functional) — no // verifies: marker; nothing tests that the code satisfies it` });
|
|
86
|
+
}
|
|
87
|
+
}
|
|
53
88
|
return findings;
|
|
54
89
|
}
|
|
55
90
|
/**
|
|
56
91
|
* Read markers from `srcDir` and the snapshot at `snapshotPath`, and run the check. The convenience
|
|
57
92
|
* a consumer project's test calls: `const { ok, findings } = driftCheck({ srcDir, snapshotPath })`.
|
|
93
|
+
* `// verifies:` markers are read from the same tree (including its test files).
|
|
58
94
|
*/
|
|
59
95
|
export function driftCheck(opts) {
|
|
60
96
|
const markers = readMarkers(opts.srcDir);
|
|
97
|
+
const verifyMarkers = readVerifyMarkers(opts.srcDir);
|
|
61
98
|
const snapshot = existsSync(opts.snapshotPath) ? readSnapshot(opts.snapshotPath) : null;
|
|
62
|
-
const findings = checkDrift(markers, snapshot);
|
|
99
|
+
const findings = checkDrift(markers, snapshot, verifyMarkers);
|
|
63
100
|
return { ok: findings.length === 0, findings };
|
|
64
101
|
}
|
package/dist/implements.d.ts
CHANGED
|
@@ -6,8 +6,23 @@ export interface Marker {
|
|
|
6
6
|
/** Entries that are not bare identifiers — reported instead of silently ignored. */
|
|
7
7
|
malformed: string[];
|
|
8
8
|
}
|
|
9
|
+
export interface VerifyMarker {
|
|
10
|
+
/** Path relative to the scanned root. */
|
|
11
|
+
file: string;
|
|
12
|
+
line: number;
|
|
13
|
+
/** Expectation ids this test claims to verify. */
|
|
14
|
+
ids: string[];
|
|
15
|
+
/** Entries that are not expectation ids — reported instead of silently ignored. */
|
|
16
|
+
malformed: string[];
|
|
17
|
+
}
|
|
9
18
|
/** Every `// implements:` marker under `root`, with the terms it names and any malformed entries. */
|
|
10
19
|
export declare function readMarkers(root: string): Marker[];
|
|
20
|
+
/**
|
|
21
|
+
* Every `// verifies:` marker under `root`, with the expectation ids it names and any malformed
|
|
22
|
+
* entries. Unlike {@link readMarkers}, this scans `.test.ts` files too — the verifying test is the
|
|
23
|
+
* thing being marked.
|
|
24
|
+
*/
|
|
25
|
+
export declare function readVerifyMarkers(root: string): VerifyMarker[];
|
|
11
26
|
export interface TermRecord {
|
|
12
27
|
name: string;
|
|
13
28
|
type: string;
|
package/dist/implements.js
CHANGED
|
@@ -1,43 +1,58 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Reading the `// implements:` markers and the committed glossary snapshot.
|
|
2
|
+
* Reading the `// implements:` and `// verifies:` markers and the committed glossary snapshot.
|
|
3
3
|
*
|
|
4
|
-
* The markers are the only link from a term back to the code responsible for it,
|
|
5
|
-
* nothing checks is a comment that rots. This makes them parseable so a test can fail
|
|
6
|
-
* glossary and the code drift apart — a term with no implementer, or a marker naming a term
|
|
7
|
-
* longer exists.
|
|
4
|
+
* The markers are the only link from a term (or an expectation) back to the code responsible for it,
|
|
5
|
+
* and a comment nothing checks is a comment that rots. This makes them parseable so a test can fail
|
|
6
|
+
* when the glossary and the code drift apart — a term with no implementer, or a marker naming a term
|
|
7
|
+
* that no longer exists.
|
|
8
8
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
9
|
+
* Two marker kinds, and their differences are deliberate:
|
|
10
|
+
*
|
|
11
|
+
* - **`// implements: <Term>`** links code to a term. Terms are implemented by *production* code, so
|
|
12
|
+
* these are read from source and `.test.ts` files are skipped. Grammar: comma-separated bare
|
|
13
|
+
* identifiers (`Task`, `createTask`).
|
|
14
|
+
* - **`// verifies: <expectation-id>`** links a *test* to the expectation it exercises (GH #96). These
|
|
15
|
+
* live in test files by nature, so the scan *includes* `.test.ts`. Expectation ids are hyphenated
|
|
16
|
+
* (`e-001`), which the identifier grammar forbids — so they get their own id grammar.
|
|
17
|
+
*
|
|
18
|
+
* Either grammar is strict: comma-separated tokens and nothing else. Trailing prose would have to be
|
|
19
|
+
* guessed at, so anything that is not a valid token is reported rather than skipped. Put the prose on
|
|
20
|
+
* the next line.
|
|
12
21
|
*
|
|
13
22
|
* (Ported from the reference `app/` on the `backup/todo-app` branch, now a package a consumer project
|
|
14
23
|
* depends on so the check ships with Spectra rather than being copied per project.)
|
|
15
24
|
*/
|
|
16
25
|
import { readFileSync, readdirSync, statSync } from 'node:fs';
|
|
17
26
|
import path from 'node:path';
|
|
18
|
-
const MARKER = /^\s*(?:\/\/|\*)\s*implements:\s*(.*)$/;
|
|
19
27
|
const IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
28
|
+
/** An expectation id like `e-001` — a lowercase-ish prefix, a hyphen, then digits. */
|
|
29
|
+
const EXPECTATION_ID = /^[A-Za-z]+-\d+$/;
|
|
20
30
|
const SOURCE = /\.tsx?$/;
|
|
21
|
-
function sourceFiles(dir, root = dir) {
|
|
31
|
+
function sourceFiles(dir, includeTests, root = dir) {
|
|
22
32
|
const found = [];
|
|
23
33
|
for (const entry of readdirSync(dir).sort()) {
|
|
24
34
|
const full = path.join(dir, entry);
|
|
25
35
|
if (statSync(full).isDirectory()) {
|
|
26
|
-
found.push(...sourceFiles(full, root));
|
|
36
|
+
found.push(...sourceFiles(full, includeTests, root));
|
|
27
37
|
}
|
|
28
|
-
else if (SOURCE.test(entry) && !entry.endsWith('.test.ts')) {
|
|
38
|
+
else if (SOURCE.test(entry) && (includeTests || !entry.endsWith('.test.ts'))) {
|
|
29
39
|
found.push(path.relative(root, full));
|
|
30
40
|
}
|
|
31
41
|
}
|
|
32
42
|
return found;
|
|
33
43
|
}
|
|
34
|
-
/**
|
|
35
|
-
|
|
44
|
+
/**
|
|
45
|
+
* Every `<keyword>:` marker under `root`, split into valid entries and malformed ones. Shared by
|
|
46
|
+
* both marker kinds so the file-walk and the comment grammar (`//` or a `*` JSDoc continuation) live
|
|
47
|
+
* in one place; the caller supplies what a valid entry looks like and whether tests are scanned.
|
|
48
|
+
*/
|
|
49
|
+
function collect(root, keyword, valid, includeTests) {
|
|
50
|
+
const line = new RegExp(String.raw `^\s*(?:\/\/|\*)\s*${keyword}:\s*(.*)$`);
|
|
36
51
|
const markers = [];
|
|
37
|
-
for (const file of sourceFiles(root)) {
|
|
52
|
+
for (const file of sourceFiles(root, includeTests)) {
|
|
38
53
|
const lines = readFileSync(path.join(root, file), 'utf8').split('\n');
|
|
39
|
-
lines.forEach((
|
|
40
|
-
const match =
|
|
54
|
+
lines.forEach((text, index) => {
|
|
55
|
+
const match = line.exec(text);
|
|
41
56
|
if (!match)
|
|
42
57
|
return;
|
|
43
58
|
const entries = match[1]
|
|
@@ -47,13 +62,35 @@ export function readMarkers(root) {
|
|
|
47
62
|
markers.push({
|
|
48
63
|
file,
|
|
49
64
|
line: index + 1,
|
|
50
|
-
|
|
51
|
-
malformed: entries.filter((entry) => !
|
|
65
|
+
entries: entries.filter((entry) => valid.test(entry)),
|
|
66
|
+
malformed: entries.filter((entry) => !valid.test(entry)),
|
|
52
67
|
});
|
|
53
68
|
});
|
|
54
69
|
}
|
|
55
70
|
return markers;
|
|
56
71
|
}
|
|
72
|
+
/** Every `// implements:` marker under `root`, with the terms it names and any malformed entries. */
|
|
73
|
+
export function readMarkers(root) {
|
|
74
|
+
return collect(root, 'implements', IDENTIFIER, false).map((m) => ({
|
|
75
|
+
file: m.file,
|
|
76
|
+
line: m.line,
|
|
77
|
+
terms: m.entries,
|
|
78
|
+
malformed: m.malformed,
|
|
79
|
+
}));
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Every `// verifies:` marker under `root`, with the expectation ids it names and any malformed
|
|
83
|
+
* entries. Unlike {@link readMarkers}, this scans `.test.ts` files too — the verifying test is the
|
|
84
|
+
* thing being marked.
|
|
85
|
+
*/
|
|
86
|
+
export function readVerifyMarkers(root) {
|
|
87
|
+
return collect(root, 'verifies', EXPECTATION_ID, true).map((m) => ({
|
|
88
|
+
file: m.file,
|
|
89
|
+
line: m.line,
|
|
90
|
+
ids: m.entries,
|
|
91
|
+
malformed: m.malformed,
|
|
92
|
+
}));
|
|
93
|
+
}
|
|
57
94
|
/**
|
|
58
95
|
* The glossary as this project sees it — a committed file (`export_specs` writes it), not a directory
|
|
59
96
|
* somewhere else. `specs/` is not reachable from a standalone copy of the project, nor from inside
|
package/dist/index.d.ts
CHANGED
|
@@ -3,8 +3,9 @@
|
|
|
3
3
|
* implements it, as a small package a consumer project depends on.
|
|
4
4
|
*
|
|
5
5
|
* A project keeps a `specs.snapshot.json` (written by @coder's `export_specs`, committed with the
|
|
6
|
-
* code)
|
|
7
|
-
*
|
|
6
|
+
* code), `// implements: <Term>` markers in its source, and `// verifies: <expectation-id>` markers
|
|
7
|
+
* on the tests that exercise its expectations. This checks all three agree — offline, with no
|
|
8
|
+
* coordinator running — so it works both in a bare copy of the project and inside @coder's sandbox.
|
|
8
9
|
* It replaces the per-project copied files the check used to be (see `backup/todo-app`).
|
|
9
10
|
*
|
|
10
11
|
* Usage in a consumer project's test (framework-agnostic core; example in vitest):
|
|
@@ -15,8 +16,11 @@
|
|
|
15
16
|
* const { ok, findings } = driftCheck({ srcDir: 'src', snapshotPath: 'specs.snapshot.json' })
|
|
16
17
|
* expect(ok, findings.map((f) => f.message).join('\n')).toBe(true)
|
|
17
18
|
* })
|
|
19
|
+
*
|
|
20
|
+
* A test that exercises expectation `e-001` marks itself with `// verifies: e-001` (in the test file),
|
|
21
|
+
* the same way production code marks `// implements: <Term>`.
|
|
18
22
|
*/
|
|
19
|
-
export { readMarkers, readSnapshot, implementersOf } from './implements.js';
|
|
20
|
-
export type { Marker, Snapshot, TermRecord, ExpectationRecord } from './implements.js';
|
|
23
|
+
export { readMarkers, readVerifyMarkers, readSnapshot, implementersOf } from './implements.js';
|
|
24
|
+
export type { Marker, VerifyMarker, Snapshot, TermRecord, ExpectationRecord } from './implements.js';
|
|
21
25
|
export { checkDrift, driftCheck } from './check.js';
|
|
22
26
|
export type { DriftFinding, DriftFindingKind } from './check.js';
|
package/dist/index.js
CHANGED
|
@@ -3,8 +3,9 @@
|
|
|
3
3
|
* implements it, as a small package a consumer project depends on.
|
|
4
4
|
*
|
|
5
5
|
* A project keeps a `specs.snapshot.json` (written by @coder's `export_specs`, committed with the
|
|
6
|
-
* code)
|
|
7
|
-
*
|
|
6
|
+
* code), `// implements: <Term>` markers in its source, and `// verifies: <expectation-id>` markers
|
|
7
|
+
* on the tests that exercise its expectations. This checks all three agree — offline, with no
|
|
8
|
+
* coordinator running — so it works both in a bare copy of the project and inside @coder's sandbox.
|
|
8
9
|
* It replaces the per-project copied files the check used to be (see `backup/todo-app`).
|
|
9
10
|
*
|
|
10
11
|
* Usage in a consumer project's test (framework-agnostic core; example in vitest):
|
|
@@ -15,6 +16,9 @@
|
|
|
15
16
|
* const { ok, findings } = driftCheck({ srcDir: 'src', snapshotPath: 'specs.snapshot.json' })
|
|
16
17
|
* expect(ok, findings.map((f) => f.message).join('\n')).toBe(true)
|
|
17
18
|
* })
|
|
19
|
+
*
|
|
20
|
+
* A test that exercises expectation `e-001` marks itself with `// verifies: e-001` (in the test file),
|
|
21
|
+
* the same way production code marks `// implements: <Term>`.
|
|
18
22
|
*/
|
|
19
|
-
export { readMarkers, readSnapshot, implementersOf } from './implements.js';
|
|
23
|
+
export { readMarkers, readVerifyMarkers, readSnapshot, implementersOf } from './implements.js';
|
|
20
24
|
export { checkDrift, driftCheck } from './check.js';
|
package/package.json
CHANGED
|
@@ -1,17 +1,23 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@abseed/spectra-drift-check",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Offline drift check between a Spectra glossary and the code that implements it.",
|
|
5
|
-
"type": "module",
|
|
6
5
|
"license": "Apache-2.0",
|
|
7
6
|
"author": "lpezet",
|
|
8
|
-
"homepage": "https://github.com/
|
|
7
|
+
"homepage": "https://github.com/ABSeedAI/spectra/tree/main/packages/drift-check",
|
|
9
8
|
"repository": {
|
|
10
9
|
"type": "git",
|
|
11
|
-
"url": "git+https://github.com/
|
|
10
|
+
"url": "git+https://github.com/ABSeedAI/spectra.git",
|
|
12
11
|
"directory": "packages/drift-check"
|
|
13
12
|
},
|
|
14
|
-
"keywords": [
|
|
13
|
+
"keywords": [
|
|
14
|
+
"spectra",
|
|
15
|
+
"spec-driven",
|
|
16
|
+
"glossary",
|
|
17
|
+
"drift-check",
|
|
18
|
+
"vitest"
|
|
19
|
+
],
|
|
20
|
+
"type": "module",
|
|
15
21
|
"main": "./dist/index.js",
|
|
16
22
|
"types": "./dist/index.d.ts",
|
|
17
23
|
"exports": {
|
|
@@ -20,20 +26,9 @@
|
|
|
20
26
|
"default": "./dist/index.js"
|
|
21
27
|
}
|
|
22
28
|
},
|
|
23
|
-
"files": ["dist", "NOTICE"],
|
|
24
29
|
"sideEffects": false,
|
|
30
|
+
"dependencies": {},
|
|
25
31
|
"publishConfig": {
|
|
26
32
|
"access": "public"
|
|
27
|
-
},
|
|
28
|
-
"scripts": {
|
|
29
|
-
"build": "tsc -p tsconfig.build.json",
|
|
30
|
-
"test": "vitest run",
|
|
31
|
-
"test:watch": "vitest",
|
|
32
|
-
"typecheck": "tsc --noEmit",
|
|
33
|
-
"prepublishOnly": "npm run build"
|
|
34
|
-
},
|
|
35
|
-
"devDependencies": {
|
|
36
|
-
"typescript": "^5.7.3",
|
|
37
|
-
"vitest": "^3.0.5"
|
|
38
33
|
}
|
|
39
34
|
}
|