doctrina-cli 0.15.0 → 0.16.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 +1 -1
- package/package.json +1 -1
- package/src/commands/adapter.js +24 -1
- package/src/commands/analyze.js +8 -199
- package/src/commands/change.js +143 -217
- package/src/commands/ci.js +182 -0
- package/src/commands/clarify.js +58 -21
- package/src/commands/close.js +291 -42
- package/src/commands/constitution.js +14 -91
- package/src/commands/context.js +278 -107
- package/src/commands/contract.js +95 -75
- package/src/commands/coverage.js +58 -273
- package/src/commands/decision.js +54 -7
- package/src/commands/doctor.js +292 -129
- package/src/commands/handoff.js +10 -57
- package/src/commands/hooks.js +11 -2
- package/src/commands/index-rebuild.js +9 -48
- package/src/commands/init.js +70 -13
- package/src/commands/intake.js +30 -92
- package/src/commands/metrics.js +53 -84
- package/src/commands/next.js +105 -147
- package/src/commands/prime.js +24 -91
- package/src/commands/report.js +50 -108
- package/src/commands/review.js +68 -36
- package/src/commands/search.js +18 -4
- package/src/commands/show.js +24 -9
- package/src/commands/skill.js +16 -25
- package/src/commands/spec.js +26 -5
- package/src/commands/status.js +51 -172
- package/src/commands/templates.js +31 -138
- package/src/commands/trace.js +36 -71
- package/src/commands/triage.js +7 -75
- package/src/commands/validate.js +14 -871
- package/src/commands/verify.js +121 -160
- package/src/commands/why.js +2 -2
- package/src/commands/work.js +234 -263
- package/src/index.js +106 -7
- package/src/lib/actions.js +484 -0
- package/src/lib/adapters.js +0 -11
- package/src/lib/adr-guard.js +19 -0
- package/src/lib/agent-changelog.js +133 -0
- package/src/lib/analysis.js +264 -0
- package/src/lib/change-ops.js +131 -0
- package/src/lib/commands.js +109 -6
- package/src/lib/config.js +206 -0
- package/src/lib/constitution-model.js +102 -0
- package/src/lib/coverage-model.js +392 -0
- package/src/lib/criteria.js +23 -0
- package/src/lib/doc-model.js +326 -0
- package/src/lib/docs-impact.js +196 -27
- package/src/lib/flag-catalog.js +0 -16
- package/src/lib/fs-ops.js +58 -1
- package/src/lib/gates.js +210 -24
- package/src/lib/git.js +132 -0
- package/src/lib/index-json.js +2 -19
- package/src/lib/intake-model.js +86 -0
- package/src/lib/json-out.js +100 -2
- package/src/lib/ledger.js +192 -0
- package/src/lib/lexicon.js +191 -0
- package/src/lib/metrics-model.js +159 -0
- package/src/lib/names.js +42 -0
- package/src/lib/playbook.js +97 -0
- package/src/lib/project.js +57 -0
- package/src/lib/reproducibility.js +137 -0
- package/src/lib/runtime.js +238 -11
- package/src/lib/scan.js +204 -13
- package/src/lib/signoff.js +147 -0
- package/src/lib/snapshot.js +209 -0
- package/src/lib/spec-ops.js +50 -5
- package/src/lib/templates-model.js +262 -0
- package/src/lib/trace-model.js +95 -0
- package/src/lib/triage-model.js +121 -0
- package/src/lib/usage.js +6 -4
- package/src/lib/validation-model.js +1299 -0
- package/src/lib/version.js +22 -0
- package/src/lib/views.js +410 -0
- package/src/lib/work-model.js +77 -0
- package/templates/AGENTS.md.template +20 -26
- package/templates/README.md +35 -0
- package/templates/change/proposal.md.template +1 -0
- package/templates/contract.md.template +7 -0
- package/templates/hooks/pre-commit.sample +6 -2
- package/templates/playbooks/bootstrap.md.template +43 -0
- package/templates/playbooks/chore.md.template +19 -0
- package/templates/playbooks/work.md.template +76 -0
package/src/lib/names.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
// One grammar for the NAME of a spec, a contract or a skill (change 0110).
|
|
3
|
+
//
|
|
4
|
+
// Three commands carried their own copy of `/^[a-z][a-z0-9-]*$/`, and none
|
|
5
|
+
// of them knew anything about a file system. Measured on Windows: `spec new
|
|
6
|
+
// nul` created `.doctrina/specs/nul/spec.md` — a directory Node could write
|
|
7
|
+
// and git could neither see nor remove (`could not open directory`, `failed
|
|
8
|
+
// to remove`), gone only through a `\\?\` path. `con`, `prn`, `aux`,
|
|
9
|
+
// `com1`-`com9` and `lpt1`-`lpt9` are device names on Windows, and this
|
|
10
|
+
// repository's own tree is Windows/CRLF. A 120-character name passed too
|
|
11
|
+
// (`Filename too long` from git), as did `trail-` and `a--b`.
|
|
12
|
+
//
|
|
13
|
+
// A name is a directory name on every file system the project will touch.
|
|
14
|
+
// The message names the rule that failed, so the fix is the invocation.
|
|
15
|
+
|
|
16
|
+
const RESERVED = new Set([
|
|
17
|
+
"con", "prn", "aux", "nul",
|
|
18
|
+
...Array.from({ length: 9 }, (_, i) => `com${i + 1}`),
|
|
19
|
+
...Array.from({ length: 9 }, (_, i) => `lpt${i + 1}`),
|
|
20
|
+
]);
|
|
21
|
+
|
|
22
|
+
const NAME_MAX = 64;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* null when `name` is a legal artifact name; otherwise the reason, ready to
|
|
26
|
+
* print after "error:".
|
|
27
|
+
*
|
|
28
|
+
* @param {string | undefined} name
|
|
29
|
+
* @param {string} kind "capability" | "contract name" | "skill name"
|
|
30
|
+
* @returns {string | null}
|
|
31
|
+
*/
|
|
32
|
+
export function artifactNameError(name, kind) {
|
|
33
|
+
const value = String(name ?? "");
|
|
34
|
+
if (value === "") return `${kind} is required`;
|
|
35
|
+
if (!/^[a-z0-9-]+$/.test(value)) return `${kind} must be lowercase letters, digits, or hyphens (got "${value}")`;
|
|
36
|
+
if (!/^[a-z]/.test(value)) return `${kind} must start with a letter (got "${value}")`;
|
|
37
|
+
if (/-$/.test(value)) return `${kind} must not end with a hyphen (got "${value}")`;
|
|
38
|
+
if (/--/.test(value)) return `${kind} must not contain a doubled hyphen (got "${value}")`;
|
|
39
|
+
if (value.length > NAME_MAX) return `${kind} must be at most ${NAME_MAX} characters (got ${value.length})`;
|
|
40
|
+
if (RESERVED.has(value)) return `${kind} "${value}" is a reserved device name on Windows — a directory git cannot see or remove there; pick another`;
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import { c } from "./colors.js";
|
|
3
|
+
import { readTemplate, substitute } from "./templates.js";
|
|
4
|
+
|
|
5
|
+
// Playbooks are TEMPLATES, not code (audit finding F17).
|
|
6
|
+
//
|
|
7
|
+
// `printPlaybook()` was ~100 lines of literal `console.log` — including the
|
|
8
|
+
// documentation of the ops grammar — and the same procedure was written out
|
|
9
|
+
// again in AGENTS.md and twice more in docs/{en,pt}/workflow.md. Four homes
|
|
10
|
+
// for one fact, in the framework whose first design principle is that a fact
|
|
11
|
+
// has exactly one.
|
|
12
|
+
//
|
|
13
|
+
// Worse for adopters: a team whose process differs (an extra review step, a
|
|
14
|
+
// different close) had no way to say so. Every other scaffold resolves
|
|
15
|
+
// project-over-bundled per file (ADR 0019); the procedure the agent actually
|
|
16
|
+
// executes was the one thing hard-coded.
|
|
17
|
+
//
|
|
18
|
+
// So a playbook resolves through the same chain, and this module renders it.
|
|
19
|
+
//
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// The markup
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
//
|
|
24
|
+
// Colour has to survive the move, or the output changes and the migration is
|
|
25
|
+
// not faithful. The template marks spans inline:
|
|
26
|
+
//
|
|
27
|
+
// [[c]]doctrina close 0001[[/c]] cyan — a command to run
|
|
28
|
+
// [[g]]...[[/g]] gray — commentary
|
|
29
|
+
// [[b]]...[[/b]] bold — a heading
|
|
30
|
+
// [[y]]...[[/y]] yellow — a warning
|
|
31
|
+
//
|
|
32
|
+
// Markup is expanded BEFORE token substitution, deliberately: a token's value
|
|
33
|
+
// can carry a user's prompt, and a prompt must never be able to inject colour
|
|
34
|
+
// into the playbook. Blocks the CLI computes (a capability ranking, a warning
|
|
35
|
+
// list) arrive already coloured through `c.*`, so they need no markup.
|
|
36
|
+
//
|
|
37
|
+
// Everything variable is a TOKEN, and the code pre-renders it. That answers
|
|
38
|
+
// the open question the change carried — extend the substituter, or
|
|
39
|
+
// pre-render? — in favour of pre-rendering: `substitute` stays a plain
|
|
40
|
+
// string map, and the template stays a document rather than becoming a
|
|
41
|
+
// programming language with conditionals and loops in it.
|
|
42
|
+
|
|
43
|
+
const MARKS = { c: c.cyan, g: c.gray, b: c.bold, y: c.yellow };
|
|
44
|
+
|
|
45
|
+
/** Expand `[[x]]...[[/x]]` colour spans. Innermost first, so spans nest. */
|
|
46
|
+
export function expandMarkup(text) {
|
|
47
|
+
let out = String(text);
|
|
48
|
+
let previous;
|
|
49
|
+
do {
|
|
50
|
+
previous = out;
|
|
51
|
+
out = out.replace(/\[\[([cgby])\]\]((?:(?!\[\[[cgby]\]\])[\s\S])*?)\[\[\/\1\]\]/g,
|
|
52
|
+
(_, mark, body) => MARKS[mark](body));
|
|
53
|
+
} while (out !== previous);
|
|
54
|
+
return out;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Render a named playbook, resolved project-over-bundled.
|
|
59
|
+
*
|
|
60
|
+
* Returns the text; the caller prints it. Keeping the write at the edge is
|
|
61
|
+
* what lets a test assert the rendering without capturing stdout.
|
|
62
|
+
*/
|
|
63
|
+
export function renderPlaybook(projectRoot, name, tokens = {}) {
|
|
64
|
+
const tpl = readTemplate(projectRoot, `playbooks/${name}.md.template`);
|
|
65
|
+
// A trailing newline in the file would print an extra blank line; the
|
|
66
|
+
// caller decides its own spacing.
|
|
67
|
+
return substitute(expandMarkup(dropEmptyBlocks(tpl.body, tokens)), tokens).replace(/\n$/, "");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* A line holding NOTHING but one token, whose value is empty or absent, is
|
|
72
|
+
* removed rather than left as a blank line.
|
|
73
|
+
*
|
|
74
|
+
* That single rule is what lets the optional parts of a playbook — the
|
|
75
|
+
* thin-prompt warning, the capability hint, the backfill note — be plain
|
|
76
|
+
* tokens on their own line instead of conditionals in a template language.
|
|
77
|
+
* A block that IS present brings its own spacing in its value.
|
|
78
|
+
*/
|
|
79
|
+
function dropEmptyBlocks(body, tokens) {
|
|
80
|
+
return body
|
|
81
|
+
.split("\n")
|
|
82
|
+
.filter((line) => {
|
|
83
|
+
const m = line.match(/^\s*\{\{(\w+)\}\}\s*$/);
|
|
84
|
+
if (!m) return true;
|
|
85
|
+
const value = tokens[m[1]];
|
|
86
|
+
return value !== undefined && String(value) !== "";
|
|
87
|
+
})
|
|
88
|
+
.join("\n");
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Render and print. The one place a playbook reaches stdout. */
|
|
92
|
+
export function printPlaybookTemplate(projectRoot, name, tokens = {}) {
|
|
93
|
+
console.log(renderPlaybook(projectRoot, name, tokens));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** The playbooks the CLI ships and `templates check` expects to resolve. */
|
|
97
|
+
export const PLAYBOOKS = ["work", "chore", "bootstrap"];
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { exists } from "./fs-ops.js";
|
|
4
|
+
import { notADoctrinaProject } from "./exit-codes.js";
|
|
5
|
+
|
|
6
|
+
// The one precondition every command shares: this is a Doctrina project.
|
|
7
|
+
// Six command modules carried an identical private copy of it; a lib
|
|
8
|
+
// operation that needs it should not have to reach into a command for the
|
|
9
|
+
// check (audit finding F7).
|
|
10
|
+
export function ensureDoctrinaProject(projectRoot) {
|
|
11
|
+
if (!exists(path.join(projectRoot, ".doctrina"))) {
|
|
12
|
+
throw notADoctrinaProject();
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// The grammar a change id obeys: lowercase letters, digits and hyphens,
|
|
17
|
+
// opening on a letter or a digit — `0042-short-slug` is the shape `work`
|
|
18
|
+
// derives. Deliberately NOT the capability grammar, which additionally
|
|
19
|
+
// requires a leading letter; two rules that look alike are not one rule.
|
|
20
|
+
//
|
|
21
|
+
// It exists because `change new` had NO grammar (third audit, finding 1).
|
|
22
|
+
// Every other authoring command validated its argument; `change new` joined
|
|
23
|
+
// whatever it was given onto a path, so `change new ../../../elsewhere/evil`
|
|
24
|
+
// wrote a change folder OUTSIDE the project — against the `authoring` spec's
|
|
25
|
+
// own "shall not write outside the project working directory" — and ids like
|
|
26
|
+
// `0003-com espaco` reached `validate` and `index rebuild` as legitimate,
|
|
27
|
+
// producing a `next` action whose command could not be run.
|
|
28
|
+
export const CHANGE_ID = /^[a-z0-9][a-z0-9-]*$/;
|
|
29
|
+
|
|
30
|
+
export function isChangeId(id) {
|
|
31
|
+
return typeof id === "string" && CHANGE_ID.test(id);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Resolve `segments` under `projectRoot` and refuse anything that escapes it.
|
|
36
|
+
*
|
|
37
|
+
* Defence in depth, deliberately separate from the grammar above: the
|
|
38
|
+
* grammar is what tells the USER their id is wrong, and this is what holds
|
|
39
|
+
* even when a caller forgets to ask. A path is inside when it is the root
|
|
40
|
+
* itself or sits beneath it — compared after `path.resolve`, so `..`,
|
|
41
|
+
* absolute arguments and symlink-free trickery all normalise first.
|
|
42
|
+
*
|
|
43
|
+
* Throws rather than returning a code: a caller that reached here with an
|
|
44
|
+
* escaping path has a bug, and the entrypoint's catch turns a thrown error
|
|
45
|
+
* into the gate class with the message intact.
|
|
46
|
+
*/
|
|
47
|
+
export function resolveWithinProject(projectRoot, ...segments) {
|
|
48
|
+
const root = path.resolve(projectRoot);
|
|
49
|
+
const target = path.resolve(root, ...segments);
|
|
50
|
+
const rel = path.relative(root, target);
|
|
51
|
+
if (rel.startsWith("..") || path.isAbsolute(rel)) {
|
|
52
|
+
throw new Error(
|
|
53
|
+
`refusing to write outside the project: "${path.join(...segments)}" resolves to ${target}`,
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
return target;
|
|
57
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
// The clean-checkout reproducibility lint, as a collection rather than a
|
|
3
|
+
// printout: "would a fresh clone of this repository actually build?"
|
|
4
|
+
//
|
|
5
|
+
// It lives here because two commands ask it — `verify --clean` renders it,
|
|
6
|
+
// and `doctor` reports it as one row. `doctor` used to answer by spawning
|
|
7
|
+
// the CLI again and reading its exit code, which is a second integration
|
|
8
|
+
// style inside one binary and a process per row (audit finding F4).
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
import { readdirSync } from "node:fs";
|
|
11
|
+
import { read, relPath } from "./fs-ops.js";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Static reproducibility lint: walk the project's package.json files and
|
|
15
|
+
* report the two "works on my machine" footguns the review hit — a package
|
|
16
|
+
* whose entry points live in a build-output dir with nothing that builds it
|
|
17
|
+
* on install, and a codegen dependency (Prisma) with no install hook that
|
|
18
|
+
* generates. A pure read: it returns what it found and prints nothing, so
|
|
19
|
+
* `verify --clean` renders it and `doctor` reports it as one row without
|
|
20
|
+
* spawning the CLI again to read its exit code (audit finding F4).
|
|
21
|
+
*
|
|
22
|
+
* @param {string} projectRoot
|
|
23
|
+
* @returns {{packages: number, findings: string[]}}
|
|
24
|
+
*/
|
|
25
|
+
export function collectReproducibility(projectRoot) {
|
|
26
|
+
const pkgPaths = findPackageJsons(projectRoot);
|
|
27
|
+
const findings = [];
|
|
28
|
+
for (const pkgPath of pkgPaths) {
|
|
29
|
+
const rel = relPath(projectRoot, pkgPath);
|
|
30
|
+
let pkg;
|
|
31
|
+
try {
|
|
32
|
+
pkg = JSON.parse(read(pkgPath));
|
|
33
|
+
} catch (err) {
|
|
34
|
+
findings.push(`${rel}: not valid JSON (${err.message})`);
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
const scripts = pkg.scripts ?? {};
|
|
38
|
+
const hasInstallBuild = typeof scripts.prepare === "string" || typeof scripts.prepack === "string";
|
|
39
|
+
|
|
40
|
+
// 1. Entry point into a build output with nothing to build it on install.
|
|
41
|
+
const built = entryIntoBuildDir(pkg);
|
|
42
|
+
if (built && !hasInstallBuild) {
|
|
43
|
+
findings.push(
|
|
44
|
+
`${rel}: ${built.field} → \`${built.value}\` is a build output, but no "prepare"/"prepack" ` +
|
|
45
|
+
`script builds it on install — a fresh install/clone won't have it (add a prepare script, or commit the output)`,
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// 2. Codegen dependency with no install hook that generates.
|
|
50
|
+
const deps = {
|
|
51
|
+
...pkg.dependencies, ...pkg.devDependencies,
|
|
52
|
+
...pkg.peerDependencies, ...pkg.optionalDependencies,
|
|
53
|
+
};
|
|
54
|
+
for (const [dep, gen] of Object.entries(CODEGEN_DEPS)) {
|
|
55
|
+
if (!(dep in deps)) continue;
|
|
56
|
+
const installHook = `${scripts.postinstall ?? ""} ${scripts.prepare ?? ""}`;
|
|
57
|
+
if (!gen.test(installHook)) {
|
|
58
|
+
findings.push(
|
|
59
|
+
`${rel}: depends on "${dep}" but no "postinstall"/"prepare" runs its codegen ` +
|
|
60
|
+
`(\`${gen.source.replace(/\\s\+/g, " ")}\`) — a fresh install has no generated output`,
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return { packages: pkgPaths.length, findings };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Known codegen dependencies → the command an install hook must run so a
|
|
70
|
+
// fresh install produces their generated output.
|
|
71
|
+
const CODEGEN_DEPS = {
|
|
72
|
+
"@prisma/client": /prisma\s+generate/,
|
|
73
|
+
"prisma": /prisma\s+generate/,
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const BUILD_DIR_RE = /(?:^|\/)(?:dist|build|out)\//;
|
|
77
|
+
|
|
78
|
+
// The first entry-point field that points into a build-output directory, or
|
|
79
|
+
// null. Scans main/module/types/typings, bin (string or map), and exports
|
|
80
|
+
// (recursively, string leaves only).
|
|
81
|
+
function entryIntoBuildDir(pkg) {
|
|
82
|
+
for (const field of ["main", "module", "types", "typings"]) {
|
|
83
|
+
if (typeof pkg[field] === "string" && BUILD_DIR_RE.test(pkg[field])) {
|
|
84
|
+
return { field, value: pkg[field] };
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
if (typeof pkg.bin === "string" && BUILD_DIR_RE.test(pkg.bin)) return { field: "bin", value: pkg.bin };
|
|
88
|
+
if (pkg.bin && typeof pkg.bin === "object") {
|
|
89
|
+
for (const v of Object.values(pkg.bin)) {
|
|
90
|
+
if (typeof v === "string" && BUILD_DIR_RE.test(v)) return { field: "bin", value: v };
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
const fromExports = scanExports(pkg.exports);
|
|
94
|
+
if (fromExports) return { field: "exports", value: fromExports };
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function scanExports(node) {
|
|
99
|
+
if (typeof node === "string") return BUILD_DIR_RE.test(node) ? node : null;
|
|
100
|
+
if (node && typeof node === "object") {
|
|
101
|
+
for (const v of Object.values(node)) {
|
|
102
|
+
const hit = scanExports(v);
|
|
103
|
+
if (hit) return hit;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Bounded walk for package.json files: skip dependency, build, and VCS
|
|
110
|
+
// directories so the lint stays fast and ignores vendored manifests.
|
|
111
|
+
const LINT_SKIP_DIRS = new Set([
|
|
112
|
+
".git", "node_modules", "dist", "build", "out", "vendor", ".next",
|
|
113
|
+
"coverage", ".venv", "venv", "__pycache__", "target", ".doctrina",
|
|
114
|
+
]);
|
|
115
|
+
|
|
116
|
+
function findPackageJsons(projectRoot) {
|
|
117
|
+
const found = [];
|
|
118
|
+
const stack = [projectRoot];
|
|
119
|
+
while (stack.length) {
|
|
120
|
+
const dir = stack.pop();
|
|
121
|
+
let entries;
|
|
122
|
+
try {
|
|
123
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
124
|
+
} catch {
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
for (const entry of entries) {
|
|
128
|
+
const full = path.join(dir, entry.name);
|
|
129
|
+
if (entry.isDirectory()) {
|
|
130
|
+
if (!LINT_SKIP_DIRS.has(entry.name) && !entry.name.startsWith(".")) stack.push(full);
|
|
131
|
+
} else if (entry.name === "package.json") {
|
|
132
|
+
found.push(full);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return found.sort();
|
|
137
|
+
}
|
package/src/lib/runtime.js
CHANGED
|
@@ -38,6 +38,31 @@ import { isDir, isFile, read, relPath, walk } from "./fs-ops.js";
|
|
|
38
38
|
/** Origins whose value CI can inject as an EMPTY STRING rather than unset. */
|
|
39
39
|
const INJECTABLE_ORIGINS = new Set(["vars", "secrets"]);
|
|
40
40
|
|
|
41
|
+
/**
|
|
42
|
+
* Split an Origin cell into its origin and the OPTIONAL source name it
|
|
43
|
+
* declares: `secrets` -> {origin: "secrets", source: null}, and
|
|
44
|
+
* `secrets:NPM_TOKEN` -> {origin: "secrets", source: "NPM_TOKEN"}.
|
|
45
|
+
*
|
|
46
|
+
* Exporting a variable under a name that is not the source's is routine and
|
|
47
|
+
* often unavoidable — npm reads its credential from `NODE_AUTH_TOKEN`, so a
|
|
48
|
+
* repository whose secret is `NPM_TOKEN` has no choice but to rename in the
|
|
49
|
+
* `env:` line. RT02 is right to flag that (rename the secret and the
|
|
50
|
+
* expression resolves to the empty string, so the publish fails on a blank
|
|
51
|
+
* credential with nothing pointing at the cause) and was wrong to make it
|
|
52
|
+
* unanswerable: its remedy said "record the intentional rename in the Wiring
|
|
53
|
+
* row" while the row had nowhere to put it. A permanent warning nobody can
|
|
54
|
+
* clear teaches people to ignore warnings.
|
|
55
|
+
*/
|
|
56
|
+
export function parseOrigin(cell) {
|
|
57
|
+
const raw = String(cell ?? "").trim();
|
|
58
|
+
const colon = raw.indexOf(":");
|
|
59
|
+
if (colon < 0) return { origin: raw.toLowerCase(), source: null };
|
|
60
|
+
return {
|
|
61
|
+
origin: raw.slice(0, colon).trim().toLowerCase(),
|
|
62
|
+
source: raw.slice(colon + 1).trim() || null,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
41
66
|
// ---------------------------------------------------------------------------
|
|
42
67
|
// Declaration parsing
|
|
43
68
|
// ---------------------------------------------------------------------------
|
|
@@ -255,7 +280,10 @@ export function checkWiring(projectRoot, decl) {
|
|
|
255
280
|
const cache = new Map();
|
|
256
281
|
|
|
257
282
|
for (const row of decl.wiring) {
|
|
258
|
-
const origin = row.origin
|
|
283
|
+
const { origin, source } = parseOrigin(row.origin);
|
|
284
|
+
// The name the workflow is expected to read FROM: the declared source
|
|
285
|
+
// when the row gives one, otherwise the variable's own name.
|
|
286
|
+
const expectedSource = source ?? row.name;
|
|
259
287
|
if (!INJECTABLE_ORIGINS.has(origin)) continue;
|
|
260
288
|
if (!row.workflow) {
|
|
261
289
|
findings.push({
|
|
@@ -303,7 +331,9 @@ export function checkWiring(projectRoot, decl) {
|
|
|
303
331
|
code: "RT01",
|
|
304
332
|
level: "error",
|
|
305
333
|
message: `${row.name} is declared with origin "${origin}" but no env: block in ${row.workflow} exports it${where} — the value exists in CI and never reaches the process`,
|
|
306
|
-
|
|
334
|
+
// Quote the DECLARED source, so the line this tells you to paste is
|
|
335
|
+
// the one that actually works when the names differ.
|
|
336
|
+
remedy: `add "${row.name}: \${{ ${origin}.${expectedSource} }}" to the env: block of the ${row.job || "relevant"} job in ${row.workflow}`,
|
|
307
337
|
file: row.workflow,
|
|
308
338
|
});
|
|
309
339
|
continue;
|
|
@@ -320,12 +350,19 @@ export function checkWiring(projectRoot, decl) {
|
|
|
320
350
|
remedy: `align the Origin column with ${row.workflow}:${e.line} (a value set as ${origin} is not visible as ${ref.origin})`,
|
|
321
351
|
file: row.workflow,
|
|
322
352
|
});
|
|
323
|
-
} else if (ref.name !==
|
|
353
|
+
} else if (ref.name !== expectedSource && ref.origin === origin) {
|
|
354
|
+
// Silent when the workflow reads exactly the source the row
|
|
355
|
+
// declares: the rename is documented, and documentation is the
|
|
356
|
+
// answer this warning was asking for.
|
|
324
357
|
findings.push({
|
|
325
358
|
code: "RT02",
|
|
326
359
|
level: "warn",
|
|
327
|
-
message:
|
|
328
|
-
|
|
360
|
+
message: source
|
|
361
|
+
? `${row.workflow}:${e.line} exports ${row.name} from ${ref.origin}.${ref.name}, but the contract declares it comes from ${origin}.${source} — one of the two moved`
|
|
362
|
+
: `${row.workflow}:${e.line} exports ${row.name} from ${ref.origin}.${ref.name} — the names differ, so renaming one silently empties the other`,
|
|
363
|
+
remedy: source
|
|
364
|
+
? `align them: either export \${{ ${origin}.${source} }}, or update the Origin cell to "${origin}:${ref.name}"`
|
|
365
|
+
: `use \${{ ${origin}.${row.name} }}, or declare the rename in the Origin cell as "${origin}:${ref.name}"`,
|
|
329
366
|
file: row.workflow,
|
|
330
367
|
});
|
|
331
368
|
}
|
|
@@ -371,7 +408,11 @@ export function checkEmptySemantics(projectRoot, decl) {
|
|
|
371
408
|
const cache = new Map();
|
|
372
409
|
|
|
373
410
|
for (const row of decl.wiring) {
|
|
374
|
-
|
|
411
|
+
// Through parseOrigin, so a row declaring its source (`secrets:NPM_TOKEN`)
|
|
412
|
+
// is still recognised as CI-injectable. Reading the cell raw here would
|
|
413
|
+
// make declaring a source silently switch this check off — the same
|
|
414
|
+
// class of defect being fixed, wearing a different hat.
|
|
415
|
+
const { origin } = parseOrigin(row.origin);
|
|
375
416
|
if (!INJECTABLE_ORIGINS.has(origin) || !row.consumer) continue;
|
|
376
417
|
const full = path.join(projectRoot, row.consumer);
|
|
377
418
|
if (!isFile(full)) {
|
|
@@ -532,10 +573,77 @@ export function checkSelectors(projectRoot, decl) {
|
|
|
532
573
|
return findings;
|
|
533
574
|
}
|
|
534
575
|
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
576
|
+
/**
|
|
577
|
+
* Expand one level of brace alternation: `a/{x,y}.js` -> [`a/x.js`, `a/y.js`],
|
|
578
|
+
* recursively, so several groups in one pattern all expand.
|
|
579
|
+
*
|
|
580
|
+
* Added for the `**Source:**` declarations (change 0077), where a capability
|
|
581
|
+
* that owns eleven sibling modules would otherwise need eleven globs on one
|
|
582
|
+
* header line. Braces have no meaning in a path, so this takes nothing away
|
|
583
|
+
* from the patterns that were already valid.
|
|
584
|
+
*/
|
|
585
|
+
export function expandBraces(glob) {
|
|
586
|
+
const text = String(glob);
|
|
587
|
+
const open = text.indexOf("{");
|
|
588
|
+
if (open < 0) return [text];
|
|
589
|
+
|
|
590
|
+
// The MATCHING close, by depth — `indexOf` finds the first one, which for
|
|
591
|
+
// `{a,{b,c}}` is the inner brace: the group was split at the wrong place
|
|
592
|
+
// and expanded to ["a}", "b", "c}"], matching `b`, missing `a` and `c`,
|
|
593
|
+
// and inventing `a}`. Silent, too, because SOMETHING matched, so
|
|
594
|
+
// `validate`'s dead-pattern check stayed quiet over a declaration that
|
|
595
|
+
// covered a third of what it claimed (change 0084).
|
|
596
|
+
let depth = 0;
|
|
597
|
+
let close = -1;
|
|
598
|
+
for (let i = open; i < text.length; i++) {
|
|
599
|
+
if (text[i] === "{") depth += 1;
|
|
600
|
+
else if (text[i] === "}") {
|
|
601
|
+
depth -= 1;
|
|
602
|
+
if (depth === 0) { close = i; break; }
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
// An unmatched brace expands to itself: the braces then compile as literal
|
|
606
|
+
// characters, so the pattern matches nothing and `validate` reports it
|
|
607
|
+
// rather than the caller silently covering less than it declared.
|
|
608
|
+
if (close < 0) return [text];
|
|
609
|
+
|
|
610
|
+
const head = text.slice(0, open);
|
|
611
|
+
const tail = text.slice(close + 1);
|
|
612
|
+
const out = [];
|
|
613
|
+
// Split on commas at depth zero, for the same reason: a comma inside a
|
|
614
|
+
// nested group separates that group's alternatives, not this one's.
|
|
615
|
+
const body = text.slice(open + 1, close);
|
|
616
|
+
let cur = "";
|
|
617
|
+
depth = 0;
|
|
618
|
+
const alts = [];
|
|
619
|
+
for (const ch of body) {
|
|
620
|
+
if (ch === "{") depth += 1;
|
|
621
|
+
else if (ch === "}") depth -= 1;
|
|
622
|
+
if (ch === "," && depth === 0) { alts.push(cur); cur = ""; continue; }
|
|
623
|
+
cur += ch;
|
|
624
|
+
}
|
|
625
|
+
alts.push(cur);
|
|
626
|
+
for (const alt of alts) out.push(...expandBraces(head + alt.trim() + tail));
|
|
627
|
+
return out;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
// A minimal glob: `**` spans directories, `*` stays inside one segment, and
|
|
631
|
+
// `{a,b}` alternates. Enough for the "where do my selectors live" and "which
|
|
632
|
+
// code is mine" declarations, with no dependency and no surprises.
|
|
538
633
|
export function globToRegExp(glob) {
|
|
634
|
+
// Always compile the EXPANSION, never the original. Using it only when it
|
|
635
|
+
// produced more than one alternative left `src/{a}.js` compiling with its
|
|
636
|
+
// braces as literal characters, so a one-element group matched nothing
|
|
637
|
+
// (change 0084).
|
|
638
|
+
const alternatives = expandBraces(glob);
|
|
639
|
+
if (alternatives.length > 1) {
|
|
640
|
+
return new RegExp(alternatives.map((g) => compileGlob(g).source).join("|"));
|
|
641
|
+
}
|
|
642
|
+
return compileGlob(alternatives[0]);
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
// One expanded, brace-free glob to a RegExp.
|
|
646
|
+
function compileGlob(glob) {
|
|
539
647
|
const segments = String(glob).split("/");
|
|
540
648
|
const parts = [];
|
|
541
649
|
for (let i = 0; i < segments.length; i++) {
|
|
@@ -558,7 +666,14 @@ export function globToRegExp(glob) {
|
|
|
558
666
|
return new RegExp(`^${parts.join("")}$`);
|
|
559
667
|
}
|
|
560
668
|
|
|
561
|
-
|
|
669
|
+
/**
|
|
670
|
+
* Every file under `projectRoot` matching one glob, project-relative.
|
|
671
|
+
*
|
|
672
|
+
* Exported since change 0077: `validate` asks the same question of a spec's
|
|
673
|
+
* `**Source:**` patterns that the selector check asks of a contract's — does
|
|
674
|
+
* this declaration point at anything that exists?
|
|
675
|
+
*/
|
|
676
|
+
export function filesMatching(projectRoot, glob) {
|
|
562
677
|
const re = globToRegExp(glob);
|
|
563
678
|
// Walk from the deepest literal directory prefix so a narrow glob never
|
|
564
679
|
// costs a full-tree walk.
|
|
@@ -588,6 +703,34 @@ export function parseBudgets(decl) {
|
|
|
588
703
|
return out;
|
|
589
704
|
}
|
|
590
705
|
|
|
706
|
+
/**
|
|
707
|
+
* The value this project declares for ONE budget, or `fallback` when it
|
|
708
|
+
* declares none.
|
|
709
|
+
*
|
|
710
|
+
* Every budget in this codebase has had two homes — a literal beside the
|
|
711
|
+
* check and a row in the contract's Budgets table — and every one of them
|
|
712
|
+
* drifted (change 0059, then 0072). The contract is the declaration, so it
|
|
713
|
+
* wins; the literal a caller passes is the shipped default for a project
|
|
714
|
+
* that declares nothing. A malformed contract is its own finding, never a
|
|
715
|
+
* reason for the caller to lose its check.
|
|
716
|
+
*
|
|
717
|
+
* @param {string} projectRoot
|
|
718
|
+
* @param {string} name
|
|
719
|
+
* @param {number} fallback
|
|
720
|
+
* @returns {{ value: number, declared: boolean }}
|
|
721
|
+
*/
|
|
722
|
+
export function declaredBudget(projectRoot, name, fallback) {
|
|
723
|
+
try {
|
|
724
|
+
const row = collectBudgets(projectRoot).get(name);
|
|
725
|
+
if (row && Number.isFinite(row.value) && row.value > 0) {
|
|
726
|
+
return { value: row.value, declared: true };
|
|
727
|
+
}
|
|
728
|
+
} catch {
|
|
729
|
+
// fall through to the shipped default
|
|
730
|
+
}
|
|
731
|
+
return { value: fallback, declared: false };
|
|
732
|
+
}
|
|
733
|
+
|
|
591
734
|
/** Every declared budget across every contract, keyed by limit name. */
|
|
592
735
|
export function collectBudgets(projectRoot) {
|
|
593
736
|
const out = new Map();
|
|
@@ -603,6 +746,88 @@ export function collectBudgets(projectRoot) {
|
|
|
603
746
|
// The whole runtime verdict, for every contract in the project
|
|
604
747
|
// ---------------------------------------------------------------------------
|
|
605
748
|
|
|
749
|
+
/**
|
|
750
|
+
* The STRUCTURAL half of a contract, as findings (change 0103): port
|
|
751
|
+
* collisions, environment drift against `.env.example`, and references to
|
|
752
|
+
* capability specs that do not exist. These three lived inline in
|
|
753
|
+
* `contract check` while RT01-RT05 lived here — so `close`, which drives
|
|
754
|
+
* this collection, ran the runtime half and skipped the structural one: a
|
|
755
|
+
* contract with port 8080 claimed twice and a reference to a spec that did
|
|
756
|
+
* not exist failed `contract check` with 2 errors and passed the close's
|
|
757
|
+
* runtime step as "2 declared rows hold". One collection, every driver.
|
|
758
|
+
*
|
|
759
|
+
* @param {string} projectRoot
|
|
760
|
+
* @param {string} text the contract's Markdown
|
|
761
|
+
* @returns {Finding[]}
|
|
762
|
+
*/
|
|
763
|
+
export function checkStructure(projectRoot, text) {
|
|
764
|
+
/** @type {Finding[]} */
|
|
765
|
+
const out = [];
|
|
766
|
+
const col = (headers, name) => {
|
|
767
|
+
const i = headers.findIndex((h) => h.toLowerCase() === name.toLowerCase());
|
|
768
|
+
return i < 0 ? 0 : i;
|
|
769
|
+
};
|
|
770
|
+
|
|
771
|
+
// CT01. Port collisions — two services must not claim the same port.
|
|
772
|
+
const ports = parseTable(getSection(text, "Ports"));
|
|
773
|
+
if (ports) {
|
|
774
|
+
const portCol = col(ports.headers, "port");
|
|
775
|
+
const svcCol = col(ports.headers, "service");
|
|
776
|
+
const seen = new Map();
|
|
777
|
+
for (const row of ports.rows) {
|
|
778
|
+
const port = (row[portCol] ?? "").trim();
|
|
779
|
+
if (!/^\d+$/.test(port)) continue;
|
|
780
|
+
const svc = (row[svcCol] ?? "?").trim();
|
|
781
|
+
if (seen.has(port)) {
|
|
782
|
+
out.push({
|
|
783
|
+
code: "CT01", level: "error",
|
|
784
|
+
message: `port ${port} is claimed by both "${seen.get(port)}" and "${svc}"`,
|
|
785
|
+
remedy: `give "${svc}" a port of its own in the Ports table, or drop the duplicate row`,
|
|
786
|
+
});
|
|
787
|
+
} else {
|
|
788
|
+
seen.set(port, svc);
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
// CT02. Environment drift — every declared variable must exist in
|
|
794
|
+
// .env.example (when the project keeps one).
|
|
795
|
+
const envExamplePath = path.join(projectRoot, ".env.example");
|
|
796
|
+
const env = parseTable(getSection(text, "Environment"));
|
|
797
|
+
if (env && isFile(envExamplePath)) {
|
|
798
|
+
const envExample = read(envExamplePath);
|
|
799
|
+
const varCol = col(env.headers, "variable");
|
|
800
|
+
for (const row of env.rows) {
|
|
801
|
+
const name = (row[varCol] ?? "").trim();
|
|
802
|
+
if (!/^[A-Z][A-Z0-9_]*$/.test(name)) continue;
|
|
803
|
+
const declared = new RegExp(`^\\s*(export\\s+)?${name}\\s*=`, "m").test(envExample);
|
|
804
|
+
if (!declared) {
|
|
805
|
+
out.push({
|
|
806
|
+
code: "CT02", level: "warn",
|
|
807
|
+
message: `env var ${name} is in the contract but absent from .env.example`,
|
|
808
|
+
remedy: `add "${name}=" to .env.example, or drop the row from the Environment table`,
|
|
809
|
+
file: ".env.example",
|
|
810
|
+
});
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
// CT03. Referenced capability specs must exist.
|
|
816
|
+
for (const m of getSection(text, "References").matchAll(/specs\/([a-z][a-z0-9-]*)/g)) {
|
|
817
|
+
const refCap = m[1];
|
|
818
|
+
const specRel = `.doctrina/specs/${refCap}/spec.md`;
|
|
819
|
+
if (isFile(path.join(projectRoot, specRel))) continue;
|
|
820
|
+
if (out.some((f) => f.code === "CT03" && f.file === specRel)) continue;
|
|
821
|
+
out.push({
|
|
822
|
+
code: "CT03", level: "error",
|
|
823
|
+
message: `references spec "${refCap}" but ${specRel} does not exist`,
|
|
824
|
+
remedy: `create it with \`doctrina spec new ${refCap}\`, or correct the reference`,
|
|
825
|
+
file: specRel,
|
|
826
|
+
});
|
|
827
|
+
}
|
|
828
|
+
return out;
|
|
829
|
+
}
|
|
830
|
+
|
|
606
831
|
export function contractPaths(projectRoot) {
|
|
607
832
|
const dir = path.join(projectRoot, ".doctrina", "contracts");
|
|
608
833
|
if (!isDir(dir)) return [];
|
|
@@ -619,10 +844,12 @@ export function collectRuntimeFindings(projectRoot) {
|
|
|
619
844
|
let declared = 0;
|
|
620
845
|
const files = contractPaths(projectRoot);
|
|
621
846
|
for (const file of files) {
|
|
622
|
-
const
|
|
847
|
+
const text = read(file);
|
|
848
|
+
const decl = parseRuntimeDeclaration(text);
|
|
623
849
|
declared += decl.wiring.length + decl.selectors.length;
|
|
624
850
|
const where = relPath(projectRoot, file).replaceAll("\\", "/");
|
|
625
851
|
for (const f of [
|
|
852
|
+
...checkStructure(projectRoot, text),
|
|
626
853
|
...checkWiring(projectRoot, decl),
|
|
627
854
|
...checkEmptySemantics(projectRoot, decl),
|
|
628
855
|
...checkEnums(projectRoot, decl),
|