doctrina-cli 0.3.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 +48 -11
- package/package.json +1 -1
- package/src/commands/change.js +131 -29
- package/src/commands/coverage.js +73 -20
- package/src/commands/decision.js +83 -1
- package/src/commands/index-rebuild.js +9 -0
- package/src/commands/init.js +7 -1
- package/src/commands/intake.js +16 -1
- package/src/commands/search.js +42 -7
- package/src/commands/spec.js +92 -6
- package/src/commands/trace.js +157 -0
- package/src/commands/validate.js +140 -34
- package/src/commands/verify.js +152 -2
- package/src/commands/work.js +243 -19
- package/src/index.js +6 -15
- package/src/lib/clarity.js +49 -0
- package/src/lib/index-json.js +8 -2
- package/src/lib/scan.js +8 -0
- package/src/lib/spec-ops.js +236 -0
- package/src/lib/version.js +21 -0
- package/templates/change/spec-delta.md.template +13 -2
- package/templates/decision.md.template +8 -0
- package/templates/doctrina/index.json.template +1 -1
- package/templates/doctrina/product.md.template +6 -1
- package/templates/spec.md.template +4 -0
package/src/commands/validate.js
CHANGED
|
@@ -3,12 +3,16 @@ import process from "node:process";
|
|
|
3
3
|
import { readdirSync } from "node:fs";
|
|
4
4
|
import { exists, isDir, isFile, lineCount, read, relPath, walk } from "../lib/fs-ops.js";
|
|
5
5
|
import * as idx from "../lib/index-json.js";
|
|
6
|
+
import { SCHEMA_VERSION } from "../lib/index-json.js";
|
|
7
|
+
import { cliVersion } from "../lib/version.js";
|
|
8
|
+
import { today } from "../lib/dates.js";
|
|
9
|
+
import { flagBool } from "../lib/args.js";
|
|
6
10
|
import { c } from "../lib/colors.js";
|
|
7
11
|
import { parseFrontmatter } from "./skill.js";
|
|
8
12
|
import { checkEars, isEarsSpec } from "../lib/ears.js";
|
|
9
|
-
import { specHeader, listHeader } from "../lib/scan.js";
|
|
13
|
+
import { specHeader, listHeader, deriveIndex, indexesMatch, stableStringify } from "../lib/scan.js";
|
|
10
14
|
|
|
11
|
-
export async function run(_positional,
|
|
15
|
+
export async function run(_positional, flags) {
|
|
12
16
|
const projectRoot = process.cwd();
|
|
13
17
|
const errors = [];
|
|
14
18
|
const warnings = [];
|
|
@@ -45,8 +49,36 @@ export async function run(_positional, _flags) {
|
|
|
45
49
|
}
|
|
46
50
|
|
|
47
51
|
if (index) {
|
|
48
|
-
|
|
49
|
-
|
|
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
|
+
|
|
68
|
+
if (index.$schema_version !== SCHEMA_VERSION) {
|
|
69
|
+
warnings.push(`index.json $schema_version is "${index.$schema_version}" (expected "${SCHEMA_VERSION}")`);
|
|
70
|
+
}
|
|
71
|
+
// framework_version stamp divergence (3.6). The index records which CLI
|
|
72
|
+
// manages it; a stamp behind (or absent vs.) the running CLI means the
|
|
73
|
+
// tree may predate the current schema. `index rebuild` migrates it.
|
|
74
|
+
const runningVersion = cliVersion();
|
|
75
|
+
if (!index.framework_version) {
|
|
76
|
+
warnings.push(`index.json has no framework_version (run \`doctrina index rebuild\` to stamp it)`);
|
|
77
|
+
} else if (index.framework_version !== runningVersion) {
|
|
78
|
+
warnings.push(
|
|
79
|
+
`index.json framework_version is "${index.framework_version}" but the running CLI is ` +
|
|
80
|
+
`${runningVersion} (run \`doctrina index rebuild\` to stamp/migrate)`,
|
|
81
|
+
);
|
|
50
82
|
}
|
|
51
83
|
if (!index.artifacts) {
|
|
52
84
|
errors.push("index.json missing artifacts object");
|
|
@@ -64,25 +96,38 @@ export async function run(_positional, _flags) {
|
|
|
64
96
|
if (!exists(full)) errors.push(`index.json references missing artifact: ${rel}`);
|
|
65
97
|
}
|
|
66
98
|
|
|
67
|
-
// 4b.
|
|
68
|
-
//
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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\``);
|
|
76
111
|
}
|
|
77
112
|
}
|
|
78
113
|
}
|
|
79
114
|
|
|
80
115
|
// 5. ADRs have parseable Status: header
|
|
81
116
|
const adrDir = path.join(projectRoot, ".doctrina", "decisions");
|
|
117
|
+
// Track which files claim each NNNN so a merge-time collision (two branches
|
|
118
|
+
// both allocating the same number) becomes a loud error rather than one ADR
|
|
119
|
+
// silently shadowing the other (C2).
|
|
120
|
+
const adrByNumber = new Map();
|
|
82
121
|
if (isDir(adrDir)) {
|
|
83
122
|
for (const f of walk(adrDir)) {
|
|
84
123
|
if (!f.endsWith(".md")) continue;
|
|
85
124
|
const base = path.basename(f);
|
|
125
|
+
const numMatch = base.match(/^(\d{4})-/);
|
|
126
|
+
const num = numMatch ? numMatch[1] : null;
|
|
127
|
+
if (num) {
|
|
128
|
+
if (!adrByNumber.has(num)) adrByNumber.set(num, []);
|
|
129
|
+
adrByNumber.get(num).push(relPath(projectRoot, f));
|
|
130
|
+
}
|
|
86
131
|
// ADR filenames must be NNNN-slug.md (four digits). The entire ADR
|
|
87
132
|
// toolchain — decision new/accept/supersede, index derivation, and
|
|
88
133
|
// the orphan check below — keys off this shape; a file like
|
|
@@ -105,33 +150,50 @@ export async function run(_positional, _flags) {
|
|
|
105
150
|
// 5b. ADR evidence drift. A decision is only true if reality reflects
|
|
106
151
|
// it; an accepted ADR that points at nothing — or at a file that
|
|
107
152
|
// no longer exists — is the classic "ADR decrees X, the code
|
|
108
|
-
// never did X".
|
|
109
|
-
//
|
|
153
|
+
// never did X". Two header fields anchor a decision to reality:
|
|
154
|
+
// **Evidence:** (cited at any point) and **Landed:** (the
|
|
155
|
+
// non-mutating "this is now implemented and verified here" stamp,
|
|
156
|
+
// written by `doctrina decision land`; 3.4). Both are checked for
|
|
157
|
+
// dangling citations; an accepted ADR is only flagged bare when
|
|
158
|
+
// BOTH are empty. ADRs that adopt neither header opt out entirely.
|
|
110
159
|
const evidence = listHeader(text, "Evidence");
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
const
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
160
|
+
const landed = listHeader(text, "Landed");
|
|
161
|
+
const isBare = (v) => {
|
|
162
|
+
const t = (v ?? "").trim();
|
|
163
|
+
return t === "" || t === "—" || t === "-";
|
|
164
|
+
};
|
|
165
|
+
for (const [field, value] of [["Evidence", evidence], ["Landed", landed]]) {
|
|
166
|
+
if (value === null || isBare(value)) continue;
|
|
167
|
+
for (const token of extractBacktickPaths(value)) {
|
|
168
|
+
const candidates = [path.resolve(path.dirname(f), token), path.resolve(projectRoot, token)];
|
|
169
|
+
if (!candidates.some(exists)) {
|
|
117
170
|
warnings.push(
|
|
118
|
-
`${relPath(projectRoot, f)}:
|
|
119
|
-
`
|
|
120
|
-
`nothing behind it may be drift waiting to be superseded`,
|
|
171
|
+
`${relPath(projectRoot, f)}: ${field} cites \`${token}\` which is missing on disk — ` +
|
|
172
|
+
`the decision may have drifted from the code (update it or supersede the ADR)`,
|
|
121
173
|
);
|
|
122
174
|
}
|
|
123
|
-
} else {
|
|
124
|
-
for (const token of extractBacktickPaths(evidence)) {
|
|
125
|
-
const candidates = [path.resolve(path.dirname(f), token), path.resolve(projectRoot, token)];
|
|
126
|
-
if (!candidates.some(exists)) {
|
|
127
|
-
warnings.push(
|
|
128
|
-
`${relPath(projectRoot, f)}: Evidence cites \`${token}\` which is missing on disk — ` +
|
|
129
|
-
`the decision may have drifted from the code (update the evidence or supersede the ADR)`,
|
|
130
|
-
);
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
175
|
}
|
|
134
176
|
}
|
|
177
|
+
const adrStatus = (listHeader(text, "Status") ?? "").toLowerCase();
|
|
178
|
+
if (adrStatus === "accepted" && evidence !== null && isBare(evidence) && isBare(landed)) {
|
|
179
|
+
warnings.push(
|
|
180
|
+
`${relPath(projectRoot, f)}: accepted ADR cites no implementation evidence ` +
|
|
181
|
+
`(link the file(s) that prove it, run \`doctrina decision land ${num ?? "NNNN"}\` once ` +
|
|
182
|
+
`it ships, or note "n/a — <why>"); a decision with nothing behind it may be drift ` +
|
|
183
|
+
`waiting to be superseded`,
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// 5c. Duplicate ADR numbers (C2).
|
|
189
|
+
for (const [num, files] of adrByNumber) {
|
|
190
|
+
if (files.length > 1) {
|
|
191
|
+
errors.push(
|
|
192
|
+
`ADR number ${num} is claimed by ${files.length} files (${files.join(", ")}) — ` +
|
|
193
|
+
`two branches likely allocated it concurrently; renumber all but one ` +
|
|
194
|
+
`(the index keys decisions by number, so duplicates silently shadow each other)`,
|
|
195
|
+
);
|
|
196
|
+
}
|
|
135
197
|
}
|
|
136
198
|
}
|
|
137
199
|
|
|
@@ -165,6 +227,7 @@ export async function run(_positional, _flags) {
|
|
|
165
227
|
const specsDir = path.join(projectRoot, ".doctrina", "specs");
|
|
166
228
|
if (isDir(specsDir)) {
|
|
167
229
|
const { readdirSync } = await import("node:fs");
|
|
230
|
+
const KNOWN_SPEC_HEADERS = ["Capability", "Status", "Implementation", "Version", "Last updated", "Realizes"];
|
|
168
231
|
for (const cap of readdirSync(specsDir)) {
|
|
169
232
|
const specPath = path.join(specsDir, cap, "spec.md");
|
|
170
233
|
if (isFile(specPath)) {
|
|
@@ -202,6 +265,26 @@ export async function run(_positional, _flags) {
|
|
|
202
265
|
);
|
|
203
266
|
}
|
|
204
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
|
+
}
|
|
205
288
|
}
|
|
206
289
|
}
|
|
207
290
|
}
|
|
@@ -345,6 +428,29 @@ export async function run(_positional, _flags) {
|
|
|
345
428
|
return errors.length === 0 ? 0 : 1;
|
|
346
429
|
}
|
|
347
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
|
+
|
|
348
454
|
// Find AGENTS.md files below the root (the "nearest AGENTS.md wins"
|
|
349
455
|
// hierarchy). Bounded walk: dependency, build, and VCS directories are
|
|
350
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
|
`;
|