doctrina-cli 0.4.0 → 0.6.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/README.md +31 -12
- package/package.json +1 -1
- package/src/commands/change.js +128 -38
- package/src/commands/coverage.js +73 -20
- package/src/commands/spec.js +92 -6
- package/src/commands/validate.js +76 -11
- package/src/commands/verify.js +152 -2
- package/src/commands/work.js +232 -20
- package/src/lib/spec-ops.js +236 -0
- package/templates/change/spec-delta.md.template +13 -2
package/src/commands/validate.js
CHANGED
|
@@ -5,12 +5,14 @@ import { exists, isDir, isFile, lineCount, read, relPath, walk } from "../lib/fs
|
|
|
5
5
|
import * as idx from "../lib/index-json.js";
|
|
6
6
|
import { SCHEMA_VERSION } from "../lib/index-json.js";
|
|
7
7
|
import { cliVersion } from "../lib/version.js";
|
|
8
|
+
import { today } from "../lib/dates.js";
|
|
9
|
+
import { flagBool } from "../lib/args.js";
|
|
8
10
|
import { c } from "../lib/colors.js";
|
|
9
11
|
import { parseFrontmatter } from "./skill.js";
|
|
10
12
|
import { checkEars, isEarsSpec } from "../lib/ears.js";
|
|
11
|
-
import { specHeader, listHeader } from "../lib/scan.js";
|
|
13
|
+
import { specHeader, listHeader, deriveIndex, indexesMatch, stableStringify } from "../lib/scan.js";
|
|
12
14
|
|
|
13
|
-
export async function run(_positional,
|
|
15
|
+
export async function run(_positional, flags) {
|
|
14
16
|
const projectRoot = process.cwd();
|
|
15
17
|
const errors = [];
|
|
16
18
|
const warnings = [];
|
|
@@ -47,6 +49,22 @@ export async function run(_positional, _flags) {
|
|
|
47
49
|
}
|
|
48
50
|
|
|
49
51
|
if (index) {
|
|
52
|
+
// --fix (F5): regenerate the index from the tree before validating, so a
|
|
53
|
+
// drifted index is repaired rather than reported. A full rebuild — it also
|
|
54
|
+
// absorbs orphans and migrates the framework stamp, matching
|
|
55
|
+
// `index rebuild`. Subsequent checks then see the repaired index.
|
|
56
|
+
if (flagBool(flags, "fix", false)) {
|
|
57
|
+
const derived = deriveIndex(projectRoot, index);
|
|
58
|
+
const staleStamp = (index.framework_version ?? null) !== cliVersion();
|
|
59
|
+
if (!indexesMatch(derived, index) || staleStamp) {
|
|
60
|
+
derived.framework_version = cliVersion();
|
|
61
|
+
derived.last_updated = today();
|
|
62
|
+
idx.save(projectRoot, derived);
|
|
63
|
+
console.log(c.green("fixed") + " rebuilt .doctrina/index.json from the tree");
|
|
64
|
+
index = derived;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
50
68
|
if (index.$schema_version !== SCHEMA_VERSION) {
|
|
51
69
|
warnings.push(`index.json $schema_version is "${index.$schema_version}" (expected "${SCHEMA_VERSION}")`);
|
|
52
70
|
}
|
|
@@ -78,15 +96,18 @@ export async function run(_positional, _flags) {
|
|
|
78
96
|
if (!exists(full)) errors.push(`index.json references missing artifact: ${rel}`);
|
|
79
97
|
}
|
|
80
98
|
|
|
81
|
-
// 4b.
|
|
82
|
-
//
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
99
|
+
// 4b. Index metadata drift (F5 / review G5 / G7). validate is the single
|
|
100
|
+
// source of truth about whether the project is OK, so an indexed
|
|
101
|
+
// artifact whose recorded metadata (version, implementation, status,
|
|
102
|
+
// ...) no longer matches its file is an ERROR here — not a
|
|
103
|
+
// pass-with-warning that only `index rebuild --check` would catch
|
|
104
|
+
// (the "validate says OK while next says drift" footgun). Presence
|
|
105
|
+
// drift (orphan on disk / missing file) stays the orphan warning and
|
|
106
|
+
// the missing-artifact error above; this targets the metadata that
|
|
107
|
+
// silently lies. `validate --fix` (above) regenerates instead.
|
|
108
|
+
const derived = deriveIndex(projectRoot, index);
|
|
109
|
+
for (const line of metadataDrift(index, derived)) {
|
|
110
|
+
errors.push(`${line} — run \`doctrina validate --fix\` or \`doctrina index rebuild\``);
|
|
90
111
|
}
|
|
91
112
|
}
|
|
92
113
|
}
|
|
@@ -206,6 +227,7 @@ export async function run(_positional, _flags) {
|
|
|
206
227
|
const specsDir = path.join(projectRoot, ".doctrina", "specs");
|
|
207
228
|
if (isDir(specsDir)) {
|
|
208
229
|
const { readdirSync } = await import("node:fs");
|
|
230
|
+
const KNOWN_SPEC_HEADERS = ["Capability", "Status", "Implementation", "Version", "Last updated", "Realizes"];
|
|
209
231
|
for (const cap of readdirSync(specsDir)) {
|
|
210
232
|
const specPath = path.join(specsDir, cap, "spec.md");
|
|
211
233
|
if (isFile(specPath)) {
|
|
@@ -243,6 +265,26 @@ export async function run(_positional, _flags) {
|
|
|
243
265
|
);
|
|
244
266
|
}
|
|
245
267
|
}
|
|
268
|
+
|
|
269
|
+
// 8d. Metadata-header shape (review G11). In the header block (before
|
|
270
|
+
// the first `## ` section or `<!--` comment), a known key written
|
|
271
|
+
// without the canonical `**Key:** value` form silently fails to
|
|
272
|
+
// parse — the index then falls back to a default and drifts. Warn
|
|
273
|
+
// with the fix rather than letting the mis-typed header vanish.
|
|
274
|
+
const headerLines = text.split(/\r?\n/);
|
|
275
|
+
for (let i = 0; i < headerLines.length; i++) {
|
|
276
|
+
if (/^\s*(##\s|<!--)/.test(headerLines[i])) break;
|
|
277
|
+
for (const key of KNOWN_SPEC_HEADERS) {
|
|
278
|
+
const attempt = new RegExp(`^\\s*-?\\s*\\**${key}\\**\\s*:`, "i");
|
|
279
|
+
const canonical = new RegExp(`^(?:-\\s+)?\\*\\*${key}:\\*\\*\\s+\\S`);
|
|
280
|
+
if (attempt.test(headerLines[i]) && !canonical.test(headerLines[i])) {
|
|
281
|
+
warnings.push(
|
|
282
|
+
`${relPath(projectRoot, specPath)}:${i + 1} metadata header "${key}" is not in ` +
|
|
283
|
+
`canonical "**${key}:** value" form — it will not parse (G11)`,
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
246
288
|
}
|
|
247
289
|
}
|
|
248
290
|
}
|
|
@@ -386,6 +428,29 @@ export async function run(_positional, _flags) {
|
|
|
386
428
|
return errors.length === 0 ? 0 : 1;
|
|
387
429
|
}
|
|
388
430
|
|
|
431
|
+
// Per-entry metadata drift between the on-disk index and the tree-derived
|
|
432
|
+
// one, for entries present in BOTH. Presence drift (an artifact on disk but
|
|
433
|
+
// not indexed, or indexed but missing) is left to the orphan warning and the
|
|
434
|
+
// missing-artifact error; this isolates the case the review hit — an indexed
|
|
435
|
+
// artifact whose recorded metadata no longer matches its file.
|
|
436
|
+
function metadataDrift(current, derived) {
|
|
437
|
+
const lines = [];
|
|
438
|
+
// Skills are deliberately excluded: their description has a dedicated
|
|
439
|
+
// reconciliation command (`doctrina skill sync`) and stays an advisory
|
|
440
|
+
// warning (see section 15), not a hard error.
|
|
441
|
+
const cats = ["specs", "decisions", "changes", "changes_archive", "contracts"];
|
|
442
|
+
for (const cat of cats) {
|
|
443
|
+
const cur = new Map((current.artifacts?.[cat] ?? []).map((e) => [e.id, e]));
|
|
444
|
+
const der = new Map((derived.artifacts?.[cat] ?? []).map((e) => [e.id, e]));
|
|
445
|
+
for (const [id, entry] of cur) {
|
|
446
|
+
if (der.has(id) && stableStringify(entry) !== stableStringify(der.get(id))) {
|
|
447
|
+
lines.push(`index.json: ${cat} "${id}" metadata differs from its file`);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
return lines;
|
|
452
|
+
}
|
|
453
|
+
|
|
389
454
|
// Find AGENTS.md files below the root (the "nearest AGENTS.md wins"
|
|
390
455
|
// hierarchy). Bounded walk: dependency, build, and VCS directories are
|
|
391
456
|
// skipped so validate stays fast on large repositories.
|
package/src/commands/verify.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import process from "node:process";
|
|
3
|
+
import { readdirSync } from "node:fs";
|
|
3
4
|
import { spawnSync } from "node:child_process";
|
|
4
|
-
import { exists, isFile, read, write, relPath } from "../lib/fs-ops.js";
|
|
5
|
+
import { exists, isDir, isFile, read, write, relPath } from "../lib/fs-ops.js";
|
|
5
6
|
import { flagBool } from "../lib/args.js";
|
|
6
7
|
import { c } from "../lib/colors.js";
|
|
7
8
|
|
|
@@ -32,6 +33,17 @@ export async function run(_positional, flags) {
|
|
|
32
33
|
throw new Error("not a Doctrina project (no .doctrina/ in cwd). Run `doctrina init` first.");
|
|
33
34
|
}
|
|
34
35
|
|
|
36
|
+
// --clean is the reproducibility lint (review G4): `verify` runs the
|
|
37
|
+
// declared checks against the *current* disk, so a repo that only works
|
|
38
|
+
// because a previous run generated a Prisma client or built a workspace's
|
|
39
|
+
// dist still passes — "verify green" ≠ "passes from a clean checkout".
|
|
40
|
+
// This is the static half of that gap: it flags the footguns that make a
|
|
41
|
+
// fresh `npm install` differ from the dirty machine, without doing a real
|
|
42
|
+
// (slow, package-manager-specific) clean install.
|
|
43
|
+
if (flagBool(flags, "clean", false)) {
|
|
44
|
+
return reproducibilityLint(projectRoot);
|
|
45
|
+
}
|
|
46
|
+
|
|
35
47
|
const configPath = path.join(projectRoot, CONFIG_REL);
|
|
36
48
|
|
|
37
49
|
// --init scaffolds a starter config so the gate is one edit away.
|
|
@@ -129,8 +141,142 @@ export async function run(_positional, flags) {
|
|
|
129
141
|
return 1;
|
|
130
142
|
}
|
|
131
143
|
|
|
144
|
+
// Static reproducibility lint: walk the project's package.json files and
|
|
145
|
+
// report the two "works on my machine" footguns the review hit — a package
|
|
146
|
+
// whose entry points live in a build-output dir with nothing that builds it
|
|
147
|
+
// on install, and a codegen dependency (Prisma) with no install hook that
|
|
148
|
+
// generates. Returns 0 when clean, 1 when any risk is found. Pure read.
|
|
149
|
+
function reproducibilityLint(projectRoot) {
|
|
150
|
+
const pkgPaths = findPackageJsons(projectRoot);
|
|
151
|
+
console.log(c.bold("doctrina verify --clean") + c.gray(" — reproducibility lint (static, not a real fresh install)"));
|
|
152
|
+
console.log("");
|
|
153
|
+
|
|
154
|
+
if (pkgPaths.length === 0) {
|
|
155
|
+
console.log(c.gray("no package.json found — nothing this lint can check (it knows Node/npm footguns)"));
|
|
156
|
+
return 0;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const findings = [];
|
|
160
|
+
for (const pkgPath of pkgPaths) {
|
|
161
|
+
const rel = relPath(projectRoot, pkgPath);
|
|
162
|
+
let pkg;
|
|
163
|
+
try {
|
|
164
|
+
pkg = JSON.parse(read(pkgPath));
|
|
165
|
+
} catch (err) {
|
|
166
|
+
findings.push(`${rel}: not valid JSON (${err.message})`);
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
const scripts = pkg.scripts ?? {};
|
|
170
|
+
const hasInstallBuild = typeof scripts.prepare === "string" || typeof scripts.prepack === "string";
|
|
171
|
+
|
|
172
|
+
// 1. Entry point into a build output with nothing to build it on install.
|
|
173
|
+
const built = entryIntoBuildDir(pkg);
|
|
174
|
+
if (built && !hasInstallBuild) {
|
|
175
|
+
findings.push(
|
|
176
|
+
`${rel}: ${built.field} → \`${built.value}\` is a build output, but no "prepare"/"prepack" ` +
|
|
177
|
+
`script builds it on install — a fresh install/clone won't have it (add a prepare script, or commit the output)`,
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// 2. Codegen dependency with no install hook that generates.
|
|
182
|
+
const deps = {
|
|
183
|
+
...pkg.dependencies, ...pkg.devDependencies,
|
|
184
|
+
...pkg.peerDependencies, ...pkg.optionalDependencies,
|
|
185
|
+
};
|
|
186
|
+
for (const [dep, gen] of Object.entries(CODEGEN_DEPS)) {
|
|
187
|
+
if (!(dep in deps)) continue;
|
|
188
|
+
const installHook = `${scripts.postinstall ?? ""} ${scripts.prepare ?? ""}`;
|
|
189
|
+
if (!gen.test(installHook)) {
|
|
190
|
+
findings.push(
|
|
191
|
+
`${rel}: depends on "${dep}" but no "postinstall"/"prepare" runs its codegen ` +
|
|
192
|
+
`(\`${gen.source.replace(/\\s\+/g, " ")}\`) — a fresh install has no generated output`,
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (findings.length === 0) {
|
|
199
|
+
console.log(c.green("ok") + ` ${pkgPaths.length} package.json file${pkgPaths.length === 1 ? "" : "s"} — no reproducibility risks found`);
|
|
200
|
+
return 0;
|
|
201
|
+
}
|
|
202
|
+
for (const f of findings) console.log(` ${c.yellow("!")} ${f}`);
|
|
203
|
+
console.log("");
|
|
204
|
+
console.log(c.red("fail") + ` ${findings.length} reproducibility risk${findings.length === 1 ? "" : "s"} — a clean checkout may not build`);
|
|
205
|
+
return 1;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Known codegen dependencies → the command an install hook must run so a
|
|
209
|
+
// fresh install produces their generated output.
|
|
210
|
+
const CODEGEN_DEPS = {
|
|
211
|
+
"@prisma/client": /prisma\s+generate/,
|
|
212
|
+
"prisma": /prisma\s+generate/,
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
const BUILD_DIR_RE = /(?:^|\/)(?:dist|build|out)\//;
|
|
216
|
+
|
|
217
|
+
// The first entry-point field that points into a build-output directory, or
|
|
218
|
+
// null. Scans main/module/types/typings, bin (string or map), and exports
|
|
219
|
+
// (recursively, string leaves only).
|
|
220
|
+
function entryIntoBuildDir(pkg) {
|
|
221
|
+
for (const field of ["main", "module", "types", "typings"]) {
|
|
222
|
+
if (typeof pkg[field] === "string" && BUILD_DIR_RE.test(pkg[field])) {
|
|
223
|
+
return { field, value: pkg[field] };
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
if (typeof pkg.bin === "string" && BUILD_DIR_RE.test(pkg.bin)) return { field: "bin", value: pkg.bin };
|
|
227
|
+
if (pkg.bin && typeof pkg.bin === "object") {
|
|
228
|
+
for (const v of Object.values(pkg.bin)) {
|
|
229
|
+
if (typeof v === "string" && BUILD_DIR_RE.test(v)) return { field: "bin", value: v };
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
const fromExports = scanExports(pkg.exports);
|
|
233
|
+
if (fromExports) return { field: "exports", value: fromExports };
|
|
234
|
+
return null;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function scanExports(node) {
|
|
238
|
+
if (typeof node === "string") return BUILD_DIR_RE.test(node) ? node : null;
|
|
239
|
+
if (node && typeof node === "object") {
|
|
240
|
+
for (const v of Object.values(node)) {
|
|
241
|
+
const hit = scanExports(v);
|
|
242
|
+
if (hit) return hit;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
return null;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Bounded walk for package.json files: skip dependency, build, and VCS
|
|
249
|
+
// directories so the lint stays fast and ignores vendored manifests.
|
|
250
|
+
const LINT_SKIP_DIRS = new Set([
|
|
251
|
+
".git", "node_modules", "dist", "build", "out", "vendor", ".next",
|
|
252
|
+
"coverage", ".venv", "venv", "__pycache__", "target", ".doctrina",
|
|
253
|
+
]);
|
|
254
|
+
|
|
255
|
+
function findPackageJsons(projectRoot) {
|
|
256
|
+
const found = [];
|
|
257
|
+
const stack = [projectRoot];
|
|
258
|
+
while (stack.length) {
|
|
259
|
+
const dir = stack.pop();
|
|
260
|
+
let entries;
|
|
261
|
+
try {
|
|
262
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
263
|
+
} catch {
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
for (const entry of entries) {
|
|
267
|
+
const full = path.join(dir, entry.name);
|
|
268
|
+
if (entry.isDirectory()) {
|
|
269
|
+
if (!LINT_SKIP_DIRS.has(entry.name) && !entry.name.startsWith(".")) stack.push(full);
|
|
270
|
+
} else if (entry.name === "package.json") {
|
|
271
|
+
found.push(full);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
return found.sort();
|
|
276
|
+
}
|
|
277
|
+
|
|
132
278
|
export const help = `
|
|
133
|
-
Usage: doctrina verify [--list] [--init] [--force]
|
|
279
|
+
Usage: doctrina verify [--list] [--init] [--clean] [--force]
|
|
134
280
|
|
|
135
281
|
Run the project-declared build/verify checks from ${CONFIG_REL} in order,
|
|
136
282
|
streaming their output, and exit non-zero if any check fails. This is the
|
|
@@ -149,5 +295,9 @@ root, targets a sub-package in a monorepo):
|
|
|
149
295
|
Flags:
|
|
150
296
|
--init Scaffold a starter ${CONFIG_REL} (refuses to overwrite without --force)
|
|
151
297
|
--list Print the configured checks without running them
|
|
298
|
+
--clean Don't run checks; lint the project's package.json files for
|
|
299
|
+
reproducibility footguns (entry points in a build dir with no
|
|
300
|
+
prepare/prepack; a Prisma dep with no generate on install) so
|
|
301
|
+
"verify green" can't hide a clean checkout that won't build.
|
|
152
302
|
--force With --init, overwrite an existing config
|
|
153
303
|
`;
|