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.
@@ -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, _flags) {
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. Spec version drift — the Version: header inside each spec
82
- // must match the version recorded in index.json.
83
- for (const s of index.artifacts.specs ?? []) {
84
- const full = path.join(projectRoot, s.path);
85
- if (!isFile(full)) continue;
86
- const m = read(full).match(/^(?:-\s+)?\*\*Version:\*\*\s+(\S+)/m);
87
- if (m && s.version && m[1] !== s.version) {
88
- warnings.push(`${s.path} declares version ${m[1]} but index.json records ${s.version} (sync the index)`);
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.
@@ -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
  `;