dw-sdlc 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +63 -2
- package/config/agentic.config.schema.json +375 -0
- package/config/references.schema.json +56 -0
- package/dist/agentic-config.d.ts +167 -0
- package/dist/agentic-config.js +305 -0
- package/dist/catalogue.d.ts +36 -0
- package/dist/catalogue.js +74 -0
- package/dist/checks/agents-map.d.ts +7 -0
- package/dist/checks/agents-map.js +159 -0
- package/dist/checks/check.d.ts +27 -0
- package/dist/checks/check.js +21 -0
- package/dist/checks/comment-budget.d.ts +6 -0
- package/dist/checks/comment-budget.js +141 -0
- package/dist/checks/decision-records.d.ts +6 -0
- package/dist/checks/decision-records.js +105 -0
- package/dist/checks/file-size-ratchet.d.ts +10 -0
- package/dist/checks/file-size-ratchet.js +194 -0
- package/dist/checks/index.d.ts +12 -0
- package/dist/checks/index.js +42 -0
- package/dist/checks/markdown-budget.d.ts +15 -0
- package/dist/checks/markdown-budget.js +42 -0
- package/dist/checks/markdown-refs.d.ts +6 -0
- package/dist/checks/markdown-refs.js +73 -0
- package/dist/checks/primitives.d.ts +8 -0
- package/dist/checks/primitives.js +103 -0
- package/dist/checks/temporal-language.d.ts +6 -0
- package/dist/checks/temporal-language.js +58 -0
- package/dist/checks/tree.d.ts +8 -0
- package/dist/checks/tree.js +30 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +43 -0
- package/dist/commands/config.d.ts +2 -0
- package/dist/commands/config.js +76 -0
- package/dist/commands/doctor.d.ts +57 -0
- package/dist/commands/doctor.js +341 -0
- package/dist/commands/gate.d.ts +2 -0
- package/dist/commands/gate.js +231 -0
- package/dist/commands/primitives.d.ts +2 -0
- package/dist/commands/primitives.js +106 -0
- package/dist/commands/references-setup.d.ts +2 -0
- package/dist/commands/references-setup.js +202 -0
- package/dist/commands/references-sync.d.ts +2 -0
- package/dist/commands/references-sync.js +341 -0
- package/dist/commands/setup.d.ts +30 -0
- package/dist/commands/setup.js +352 -0
- package/dist/json-schema.d.ts +8 -0
- package/dist/json-schema.js +226 -0
- package/dist/markdown.d.ts +31 -0
- package/dist/markdown.js +115 -0
- package/dist/pins.d.ts +16 -0
- package/dist/pins.js +82 -0
- package/dist/references-key.d.ts +31 -0
- package/dist/references-key.js +149 -0
- package/dist/report.d.ts +13 -0
- package/dist/report.js +29 -0
- package/dist/router-block.d.ts +13 -0
- package/dist/router-block.js +60 -0
- package/dist/setup-rules.d.ts +22 -0
- package/dist/setup-rules.js +98 -0
- package/package.json +30 -3
- package/templates/router.md +38 -0
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
// `dw-sdlc setup` sets a repository and this machine up for the guardrails plugin: the four files its USAGE
|
|
2
|
+
// names, each written only when its content would change, so a run is safe to repeat. Existing content is
|
|
3
|
+
// merged, never replaced: other keys stay, and a settings file that is not JSON is refused.
|
|
4
|
+
import { spawnSync } from "node:child_process";
|
|
5
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
6
|
+
import { homedir } from "node:os";
|
|
7
|
+
import { dirname, join, relative } from "node:path";
|
|
8
|
+
import { pathToFileURL } from "node:url";
|
|
9
|
+
import { parseArgs } from "node:util";
|
|
10
|
+
import { COMMIT_CONVENTIONS, COMMITTED_FILE, DEFAULT_GUARDRAILS, LOCAL_FILE, SCHEMA_FILE, STRICTNESS, inferConfig, repositoryRoot, schemaId, validateDocument } from "../agentic-config.js";
|
|
11
|
+
import { GLOBAL_DENY, LEASE_RULE, RETIRED_DENY, repositoryDeny } from "../setup-rules.js";
|
|
12
|
+
export const summary = "write this machine's local config layer, the git excludes and the permission rules";
|
|
13
|
+
const USAGE = `Usage: dw-sdlc setup [--root <dir>] [--home <dir>]
|
|
14
|
+
[--convention <preset>] [--force-push deny|ask|lease] [--commit-on-protected deny|allow]
|
|
15
|
+
[--force] [--dry-run]
|
|
16
|
+
or: pnpm guardrails:setup with the same flags, in a checkout of dw-sdlc
|
|
17
|
+
|
|
18
|
+
Writes .ai/agentic.config.local.json from inference, excludes it in .git/info/exclude, generates
|
|
19
|
+
permissions.deny into .claude/settings.local.json, and adds the lease-push exception and the
|
|
20
|
+
irreversible deny set to ~/.claude/settings.json, or $CLAUDE_CONFIG_DIR/settings.json without
|
|
21
|
+
--home. From a linked worktree the repository's files go to the main checkout. Every file is
|
|
22
|
+
written only when it would change; --convention ticket-key | conventional pins commit.convention in
|
|
23
|
+
the local config, and a run without it keeps the pin as it is; a guardrails block in the local config
|
|
24
|
+
is kept, and --force-push and --commit-on-protected pin those two keys in it, refused where they would
|
|
25
|
+
loosen what the committed config sets; --force overwrites a local config that differs in anything
|
|
26
|
+
else; --dry-run reports without writing.`;
|
|
27
|
+
function isConvention(value) {
|
|
28
|
+
return COMMIT_CONVENTIONS.some((preset) => preset === value);
|
|
29
|
+
}
|
|
30
|
+
function isForcePush(value) {
|
|
31
|
+
return STRICTNESS.forcePush.some((item) => item === value);
|
|
32
|
+
}
|
|
33
|
+
function isCommitOnProtected(value) {
|
|
34
|
+
return STRICTNESS.commitOnProtected.some((item) => item === value);
|
|
35
|
+
}
|
|
36
|
+
/** Where Claude Code registers the plugin: the marketplace name and the plugin's name inside it. */
|
|
37
|
+
const MARKETPLACE = "dw-sdlc";
|
|
38
|
+
const PLUGIN = "guardrails";
|
|
39
|
+
/** The plugin's link to the dw-sdlc package, which carries the schema. */
|
|
40
|
+
const PACKAGE_LINK = "dw-sdlc";
|
|
41
|
+
function isObject(value) {
|
|
42
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
43
|
+
}
|
|
44
|
+
/** A read-only git command, the one thing the setup runs in the repository. */
|
|
45
|
+
function git(root, args) {
|
|
46
|
+
const result = spawnSync("git", ["-C", root, ...args], { encoding: "utf8" });
|
|
47
|
+
return result.status === 0 ? result.stdout.trim() : undefined;
|
|
48
|
+
}
|
|
49
|
+
/** The committed layer when it is valid; an absent or invalid one sets nothing, as the resolver drops it. */
|
|
50
|
+
function committedConfig(root) {
|
|
51
|
+
const path = join(root, COMMITTED_FILE);
|
|
52
|
+
if (!existsSync(path)) {
|
|
53
|
+
return {};
|
|
54
|
+
}
|
|
55
|
+
const result = validateDocument(COMMITTED_FILE, readFileSync(path, "utf8"));
|
|
56
|
+
return "value" in result ? result.value : {};
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* What the machine's layer points `$schema` at: the marketplace clone's schema as a `file://` URL, as an
|
|
60
|
+
* editor cannot fetch the `$id` from a private repository and the clone, unlike the install cache, keeps
|
|
61
|
+
* its path across plugin updates. Without a registered clone holding the schema, the `$id`.
|
|
62
|
+
*/
|
|
63
|
+
export function schemaReference(claudeDir) {
|
|
64
|
+
const registry = readJson(join(claudeDir, "plugins", "known_marketplaces.json"));
|
|
65
|
+
const entry = isObject(registry) ? registry[MARKETPLACE] : undefined;
|
|
66
|
+
const clone = isObject(entry) && typeof entry.installLocation === "string" ? entry.installLocation : undefined;
|
|
67
|
+
if (clone === undefined) {
|
|
68
|
+
return schemaId();
|
|
69
|
+
}
|
|
70
|
+
const manifest = readJson(join(clone, ".claude-plugin", "marketplace.json"));
|
|
71
|
+
const plugins = isObject(manifest) && Array.isArray(manifest.plugins) ? manifest.plugins : [];
|
|
72
|
+
const plugin = plugins.find((item) => isObject(item) && item.name === PLUGIN);
|
|
73
|
+
if (plugin === undefined || typeof plugin.source !== "string") {
|
|
74
|
+
return schemaId();
|
|
75
|
+
}
|
|
76
|
+
const schema = join(clone, plugin.source, PACKAGE_LINK, SCHEMA_FILE);
|
|
77
|
+
return existsSync(schema) ? pathToFileURL(schema).href : schemaId();
|
|
78
|
+
}
|
|
79
|
+
/** The convention a local file pins, when it is one the setup writes: a run without `--convention` keeps it. */
|
|
80
|
+
function pinnedConvention(existing) {
|
|
81
|
+
const commit = isObject(existing) ? existing.commit : undefined;
|
|
82
|
+
const value = isObject(commit) ? commit.convention : undefined;
|
|
83
|
+
return typeof value === "string" && isConvention(value) ? value : undefined;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* The `guardrails` block a local file holds, carried through on every run, `--force` included: the
|
|
87
|
+
* resolver lets the local layer only tighten, so carrying the block never loosens a guardrail.
|
|
88
|
+
*/
|
|
89
|
+
function keptGuardrails(existing) {
|
|
90
|
+
return isObject(existing) && isObject(existing.guardrails) ? existing.guardrails : undefined;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* A layer without the keys the setup owns: `$schema`, which moves with the clone, a pinned
|
|
94
|
+
* convention, which `--convention` may replace, and a `guardrails` object, which the setup carries
|
|
95
|
+
* and the flags pin keys in. Everything left is someone's content.
|
|
96
|
+
*/
|
|
97
|
+
function unowned(layer) {
|
|
98
|
+
const { $schema: _schema, commit, guardrails, ...other } = layer;
|
|
99
|
+
const rest = guardrails === undefined || isObject(guardrails) ? other : { ...other, guardrails };
|
|
100
|
+
if (!isObject(commit) || pinnedConvention(layer) === undefined) {
|
|
101
|
+
return commit === undefined ? rest : { ...rest, commit };
|
|
102
|
+
}
|
|
103
|
+
const { convention: _pinned, ...otherCommit } = commit;
|
|
104
|
+
return Object.keys(otherCommit).length === 0 ? rest : { ...rest, commit: otherCommit };
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* The guardrails block to write: the one the file holds, with the keys the flags pin. A pin less
|
|
108
|
+
* strict than the committed config's value is a loosening the resolver would drop the whole layer
|
|
109
|
+
* for, so it comes back as a refusal instead.
|
|
110
|
+
*/
|
|
111
|
+
function pinnedGuardrails(kept, committed, options) {
|
|
112
|
+
const base = { ...DEFAULT_GUARDRAILS, ...committed.guardrails };
|
|
113
|
+
const pins = [
|
|
114
|
+
["--force-push", options.forcePush, STRICTNESS.forcePush, base.forcePush],
|
|
115
|
+
["--commit-on-protected", options.commitOnProtected, STRICTNESS.commitOnProtected, base.commitOnProtected]
|
|
116
|
+
];
|
|
117
|
+
for (const [flag, value, order, current] of pins) {
|
|
118
|
+
if (value !== undefined && order.indexOf(value) > order.indexOf(current)) {
|
|
119
|
+
return {
|
|
120
|
+
refusal: `${flag} ${value} loosens '${current}' from ${COMMITTED_FILE}; the local layer may only tighten it`
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
const guardrails = { ...kept };
|
|
125
|
+
if (options.forcePush !== undefined) {
|
|
126
|
+
guardrails.forcePush = options.forcePush;
|
|
127
|
+
}
|
|
128
|
+
if (options.commitOnProtected !== undefined) {
|
|
129
|
+
guardrails.commitOnProtected = options.commitOnProtected;
|
|
130
|
+
}
|
|
131
|
+
return { guardrails: Object.keys(guardrails).length === 0 ? kept : guardrails };
|
|
132
|
+
}
|
|
133
|
+
/** The same layer apart from what the setup owns. */
|
|
134
|
+
function sameContent(existing, config) {
|
|
135
|
+
return isObject(existing) && render(unowned(existing)) === render(unowned(config));
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* The machine's layer: what inference finds, minus every key the committed config already sets, which an
|
|
139
|
+
* inferred key here would override, plus the `guardrails` block the file holds and the convention asked for.
|
|
140
|
+
*/
|
|
141
|
+
function localConfig(root, claudeDir, convention, guardrails) {
|
|
142
|
+
const inferred = inferConfig(root);
|
|
143
|
+
const committed = committedConfig(root);
|
|
144
|
+
const config = { $schema: schemaReference(claudeDir) };
|
|
145
|
+
const managers = { ...inferred.packageManagers };
|
|
146
|
+
if (committed.packageManagers?.node !== undefined) {
|
|
147
|
+
delete managers.node;
|
|
148
|
+
}
|
|
149
|
+
if (Object.keys(managers).length > 0) {
|
|
150
|
+
config.packageManagers = managers;
|
|
151
|
+
}
|
|
152
|
+
if (guardrails !== undefined) {
|
|
153
|
+
config.guardrails = guardrails;
|
|
154
|
+
}
|
|
155
|
+
// Only one asked for, by `--convention` or the file's pin: inference always gives `conventional`, and
|
|
156
|
+
// pinning it here would override a convention the repository commits later.
|
|
157
|
+
if (convention !== undefined) {
|
|
158
|
+
config.commit = { convention };
|
|
159
|
+
}
|
|
160
|
+
return config;
|
|
161
|
+
}
|
|
162
|
+
/** `existing` with the entries of `additions` it lacks appended: a list grows and never loses one. */
|
|
163
|
+
function union(existing, additions) {
|
|
164
|
+
const list = Array.isArray(existing) ? existing.filter((item) => typeof item === "string") : [];
|
|
165
|
+
for (const rule of additions) {
|
|
166
|
+
if (!list.includes(rule)) {
|
|
167
|
+
list.push(rule);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return list;
|
|
171
|
+
}
|
|
172
|
+
function readJsonText(text) {
|
|
173
|
+
try {
|
|
174
|
+
const parsed = JSON.parse(text);
|
|
175
|
+
return parsed;
|
|
176
|
+
}
|
|
177
|
+
catch {
|
|
178
|
+
return undefined;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
function readJson(path) {
|
|
182
|
+
if (!existsSync(path)) {
|
|
183
|
+
return undefined;
|
|
184
|
+
}
|
|
185
|
+
const parsed = readJsonText(readFileSync(path, "utf8"));
|
|
186
|
+
return isObject(parsed) ? parsed : "invalid";
|
|
187
|
+
}
|
|
188
|
+
function render(value) {
|
|
189
|
+
return JSON.stringify(value, null, 2) + "\n";
|
|
190
|
+
}
|
|
191
|
+
class Writer {
|
|
192
|
+
steps = [];
|
|
193
|
+
dryRun;
|
|
194
|
+
constructor(dryRun) {
|
|
195
|
+
this.dryRun = dryRun;
|
|
196
|
+
}
|
|
197
|
+
/** Writes when the text differs from what is on disk; records the outcome under `label`. */
|
|
198
|
+
put(label, path, text) {
|
|
199
|
+
const current = existsSync(path) ? readFileSync(path, "utf8") : undefined;
|
|
200
|
+
if (current === text) {
|
|
201
|
+
this.steps.push({ file: label, outcome: "unchanged" });
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
if (!this.dryRun) {
|
|
205
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
206
|
+
writeFileSync(path, text);
|
|
207
|
+
}
|
|
208
|
+
this.steps.push({ file: label, outcome: "wrote" });
|
|
209
|
+
}
|
|
210
|
+
refuse(label, detail) {
|
|
211
|
+
this.steps.push({ file: label, outcome: "refused", detail });
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Where the repository's files live: the main checkout for a linked worktree, since every worktree reads
|
|
216
|
+
* the local config from there when it has none of its own, and Claude Code reads the local settings there.
|
|
217
|
+
*/
|
|
218
|
+
function mainCheckout(root) {
|
|
219
|
+
const common = git(root, ["rev-parse", "--path-format=absolute", "--git-common-dir"]);
|
|
220
|
+
if (common === undefined) {
|
|
221
|
+
return { checkout: root, gitDir: join(root, ".git") };
|
|
222
|
+
}
|
|
223
|
+
return { checkout: dirname(common), gitDir: common };
|
|
224
|
+
}
|
|
225
|
+
export function setup(options) {
|
|
226
|
+
const writer = new Writer(options.dryRun);
|
|
227
|
+
const root = repositoryRoot(options.root);
|
|
228
|
+
const { checkout, gitDir } = mainCheckout(root);
|
|
229
|
+
const claudeDir = options.claudeDir ?? join(options.home, ".claude");
|
|
230
|
+
// 1. The local config, from inference, the convention (the flag's, else the one the file pins) and
|
|
231
|
+
// the guardrails block the file holds, with the keys the flags pin.
|
|
232
|
+
const localPath = join(checkout, LOCAL_FILE);
|
|
233
|
+
const localCurrent = existsSync(localPath) ? readFileSync(localPath, "utf8") : undefined;
|
|
234
|
+
const existing = localCurrent === undefined ? undefined : readJsonText(localCurrent);
|
|
235
|
+
const convention = options.convention ?? pinnedConvention(existing);
|
|
236
|
+
const pinned = pinnedGuardrails(keptGuardrails(existing), committedConfig(checkout), options);
|
|
237
|
+
if ("refusal" in pinned) {
|
|
238
|
+
writer.refuse(LOCAL_FILE, pinned.refusal);
|
|
239
|
+
}
|
|
240
|
+
else {
|
|
241
|
+
const local = localConfig(checkout, claudeDir, convention, pinned.guardrails);
|
|
242
|
+
if (localCurrent !== undefined && !sameContent(existing, local) && !options.force) {
|
|
243
|
+
writer.refuse(LOCAL_FILE, "exists with other content; pass --force to overwrite it");
|
|
244
|
+
}
|
|
245
|
+
else {
|
|
246
|
+
writer.put(LOCAL_FILE, localPath, render(local));
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
// 2. The exclude entries, appended to whatever the file holds: they keep both local files out of git
|
|
250
|
+
// without touching the repository's tracked .gitignore.
|
|
251
|
+
const excludePath = join(gitDir, "info", "exclude");
|
|
252
|
+
const excludeText = existsSync(excludePath) ? readFileSync(excludePath, "utf8") : "";
|
|
253
|
+
const lines = excludeText.split("\n");
|
|
254
|
+
const missing = [LOCAL_FILE, ".claude/settings.local.json"].filter((entry) => !lines.includes(entry));
|
|
255
|
+
const excludeLabel = relative(checkout, excludePath).startsWith("..") ? excludePath : relative(checkout, excludePath);
|
|
256
|
+
if (missing.length === 0) {
|
|
257
|
+
writer.steps.push({ file: excludeLabel, outcome: "unchanged" });
|
|
258
|
+
}
|
|
259
|
+
else {
|
|
260
|
+
const separator = excludeText === "" || excludeText.endsWith("\n") ? "" : "\n";
|
|
261
|
+
writer.put(excludeLabel, excludePath, excludeText + separator + missing.join("\n") + "\n");
|
|
262
|
+
}
|
|
263
|
+
// 3. The repository's deny rules, the backstop for the hooks where jq is missing, merged into the main
|
|
264
|
+
// checkout's local settings.
|
|
265
|
+
const localSettingsPath = join(checkout, ".claude", "settings.local.json");
|
|
266
|
+
const localSettings = readJson(localSettingsPath);
|
|
267
|
+
if (localSettings === "invalid") {
|
|
268
|
+
writer.refuse(".claude/settings.local.json", "is not a JSON object; fix it by hand first");
|
|
269
|
+
}
|
|
270
|
+
else {
|
|
271
|
+
const settings = localSettings ?? {};
|
|
272
|
+
const permissions = isObject(settings.permissions) ? settings.permissions : {};
|
|
273
|
+
const deny = union(permissions.deny, repositoryDeny(root)).filter((rule) => !RETIRED_DENY.includes(rule));
|
|
274
|
+
settings.permissions = { ...permissions, deny };
|
|
275
|
+
writer.put(".claude/settings.local.json", localSettingsPath, render(settings));
|
|
276
|
+
}
|
|
277
|
+
// 4. The user file, under $CLAUDE_CONFIG_DIR when it is set: the lease exception, "$defaults" kept, and
|
|
278
|
+
// the irreversible non-git set.
|
|
279
|
+
const userPath = join(claudeDir, "settings.json");
|
|
280
|
+
const userLabel = options.claudeDir === undefined ? "~/.claude/settings.json" : userPath;
|
|
281
|
+
const userSettings = readJson(userPath);
|
|
282
|
+
if (userSettings === "invalid") {
|
|
283
|
+
writer.refuse(userLabel, "is not a JSON object; fix it by hand first");
|
|
284
|
+
}
|
|
285
|
+
else {
|
|
286
|
+
const settings = userSettings ?? {};
|
|
287
|
+
const autoMode = isObject(settings.autoMode) ? settings.autoMode : {};
|
|
288
|
+
// A list the user wrote without "$defaults" replaced the built-ins on purpose; only a new list
|
|
289
|
+
// gets the literal.
|
|
290
|
+
const allow = Array.isArray(autoMode.allow) ? union(autoMode.allow, [LEASE_RULE]) : ["$defaults", LEASE_RULE];
|
|
291
|
+
settings.autoMode = { ...autoMode, allow };
|
|
292
|
+
const permissions = isObject(settings.permissions) ? settings.permissions : {};
|
|
293
|
+
settings.permissions = { ...permissions, deny: union(permissions.deny, GLOBAL_DENY) };
|
|
294
|
+
writer.put(userLabel, userPath, render(settings));
|
|
295
|
+
}
|
|
296
|
+
return writer.steps;
|
|
297
|
+
}
|
|
298
|
+
function fail(message, code) {
|
|
299
|
+
console.error(`guardrails:setup: ${message}`);
|
|
300
|
+
process.exit(code);
|
|
301
|
+
}
|
|
302
|
+
export function run(argv) {
|
|
303
|
+
let values;
|
|
304
|
+
try {
|
|
305
|
+
values = parseArgs({
|
|
306
|
+
args: argv,
|
|
307
|
+
options: {
|
|
308
|
+
help: { type: "boolean", short: "h" },
|
|
309
|
+
root: { type: "string" },
|
|
310
|
+
home: { type: "string" },
|
|
311
|
+
convention: { type: "string" },
|
|
312
|
+
"force-push": { type: "string" },
|
|
313
|
+
"commit-on-protected": { type: "string" },
|
|
314
|
+
force: { type: "boolean" },
|
|
315
|
+
"dry-run": { type: "boolean" }
|
|
316
|
+
}
|
|
317
|
+
}).values;
|
|
318
|
+
}
|
|
319
|
+
catch (error) {
|
|
320
|
+
fail(`${error instanceof Error ? error.message : String(error)}\n\n${USAGE}`, 2);
|
|
321
|
+
}
|
|
322
|
+
if (values.help === true) {
|
|
323
|
+
console.log(USAGE);
|
|
324
|
+
return 0;
|
|
325
|
+
}
|
|
326
|
+
if (values.convention !== undefined && !isConvention(values.convention)) {
|
|
327
|
+
fail(`--convention must be ${COMMIT_CONVENTIONS.join(" or ")}, not '${values.convention}'\n\n${USAGE}`, 2);
|
|
328
|
+
}
|
|
329
|
+
const forcePush = values["force-push"];
|
|
330
|
+
if (forcePush !== undefined && !isForcePush(forcePush)) {
|
|
331
|
+
fail(`--force-push must be ${STRICTNESS.forcePush.join(", ")}, not '${forcePush}'\n\n${USAGE}`, 2);
|
|
332
|
+
}
|
|
333
|
+
const commitOnProtected = values["commit-on-protected"];
|
|
334
|
+
if (commitOnProtected !== undefined && !isCommitOnProtected(commitOnProtected)) {
|
|
335
|
+
fail(`--commit-on-protected must be ${STRICTNESS.commitOnProtected.join(" or ")}, not '${commitOnProtected}'\n\n${USAGE}`, 2);
|
|
336
|
+
}
|
|
337
|
+
const steps = setup({
|
|
338
|
+
root: values.root ?? process.cwd(),
|
|
339
|
+
home: values.home ?? homedir(),
|
|
340
|
+
claudeDir: values.home === undefined ? process.env.CLAUDE_CONFIG_DIR : undefined,
|
|
341
|
+
force: values.force === true,
|
|
342
|
+
dryRun: values["dry-run"] === true,
|
|
343
|
+
convention: values.convention,
|
|
344
|
+
forcePush,
|
|
345
|
+
commitOnProtected
|
|
346
|
+
});
|
|
347
|
+
for (const step of steps) {
|
|
348
|
+
const verb = step.outcome === "wrote" && values["dry-run"] === true ? "would write" : step.outcome;
|
|
349
|
+
console.log(`${verb.padEnd(11)} ${step.file}${step.detail === undefined ? "" : ` — ${step.detail}`}`);
|
|
350
|
+
}
|
|
351
|
+
return steps.some((step) => step.outcome === "refused") ? 1 : 0;
|
|
352
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export type SchemaProblem = {
|
|
2
|
+
path: string;
|
|
3
|
+
message: string;
|
|
4
|
+
};
|
|
5
|
+
export declare class SchemaError extends Error {
|
|
6
|
+
}
|
|
7
|
+
/** Every problem in `value` against `schema`, in schema order; empty when it validates. */
|
|
8
|
+
export declare function validate(schema: unknown, value: unknown): SchemaProblem[];
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
// The subset of JSON Schema draft 2020-12 the shipped schemas use, validated without a dependency: Node
|
|
2
|
+
// has no validator built in, and a config file does not justify pulling one in. A keyword outside the
|
|
3
|
+
// subset throws instead of passing silently, so a schema never promises more than this file enforces.
|
|
4
|
+
export class SchemaError extends Error {
|
|
5
|
+
}
|
|
6
|
+
const ANNOTATIONS = new Set(["$schema", "$id", "$comment", "title", "description", "default", "examples"]);
|
|
7
|
+
function isObject(value) {
|
|
8
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
9
|
+
}
|
|
10
|
+
function show(value) {
|
|
11
|
+
return value === undefined ? "undefined" : JSON.stringify(value);
|
|
12
|
+
}
|
|
13
|
+
/** Equal by JSON text, as `enum` and `const` compare: exact for the scalars and arrays the schemas use. */
|
|
14
|
+
function sameJson(a, b) {
|
|
15
|
+
return JSON.stringify(a) === JSON.stringify(b);
|
|
16
|
+
}
|
|
17
|
+
function at(path) {
|
|
18
|
+
return path === "" ? "(root)" : path;
|
|
19
|
+
}
|
|
20
|
+
function child(path, key) {
|
|
21
|
+
return path === "" ? key : `${path}.${key}`;
|
|
22
|
+
}
|
|
23
|
+
function operandMust(condition, path, keyword, expectation) {
|
|
24
|
+
if (!condition) {
|
|
25
|
+
throw new SchemaError(`${at(path)}: ${keyword} must be ${expectation}`);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
function typeMatches(value, type, path) {
|
|
29
|
+
switch (type) {
|
|
30
|
+
case "object":
|
|
31
|
+
return isObject(value);
|
|
32
|
+
case "array":
|
|
33
|
+
return Array.isArray(value);
|
|
34
|
+
case "string":
|
|
35
|
+
return typeof value === "string";
|
|
36
|
+
case "number":
|
|
37
|
+
return typeof value === "number";
|
|
38
|
+
case "integer":
|
|
39
|
+
return Number.isInteger(value);
|
|
40
|
+
case "boolean":
|
|
41
|
+
return typeof value === "boolean";
|
|
42
|
+
case "null":
|
|
43
|
+
return value === null;
|
|
44
|
+
default:
|
|
45
|
+
throw new SchemaError(`${at(path)}: unknown type ${show(type)}`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
// A pattern read token by token: a bracket expression, where `(`, `?` and a backslash are plain
|
|
49
|
+
// characters and a class such as `[:alpha:]` does not close it, an escape, a `(?`, or any one character.
|
|
50
|
+
const ERE_TOKEN = /\[\^?\]?(?:\[:[a-z]+:\]|[^\]])*\]|\\[\s\S]|\(\?|[\s\S]/g;
|
|
51
|
+
const NON_ERE_TOKEN = /^(\\[dDsSwWbB]|\(\?)$/;
|
|
52
|
+
/** The first `\d`, `\s`, `\w`, `\b`, capital twin or `(?` outside a bracket expression. */
|
|
53
|
+
function notEre(value) {
|
|
54
|
+
return Array.from(value.matchAll(ERE_TOKEN), ([token]) => token).find((token) => NON_ERE_TOKEN.test(token));
|
|
55
|
+
}
|
|
56
|
+
function compile(value) {
|
|
57
|
+
try {
|
|
58
|
+
return new RegExp(value);
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return undefined;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
/** Every problem in `value` against `schema`, in schema order; empty when it validates. */
|
|
65
|
+
export function validate(schema, value) {
|
|
66
|
+
const problems = [];
|
|
67
|
+
check(schema, value, "", problems);
|
|
68
|
+
return problems;
|
|
69
|
+
}
|
|
70
|
+
function check(schema, value, path, problems) {
|
|
71
|
+
if (schema === true) {
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
if (schema === false) {
|
|
75
|
+
problems.push({ path, message: "is not allowed here" });
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
if (!isObject(schema)) {
|
|
79
|
+
throw new SchemaError(`${at(path)}: a schema must be an object or a boolean`);
|
|
80
|
+
}
|
|
81
|
+
for (const [keyword, operand] of Object.entries(schema)) {
|
|
82
|
+
checkKeyword(schema, keyword, operand, value, path, problems);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
function checkKeyword(schema, keyword, operand, value, path, problems) {
|
|
86
|
+
switch (keyword) {
|
|
87
|
+
case "type": {
|
|
88
|
+
const types = Array.isArray(operand) ? operand : [operand];
|
|
89
|
+
if (!types.some((type) => typeMatches(value, type, path))) {
|
|
90
|
+
problems.push({ path, message: `expected ${types.join(" or ")}, got ${show(value)}` });
|
|
91
|
+
}
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
case "enum": {
|
|
95
|
+
operandMust(Array.isArray(operand), path, keyword, "an array");
|
|
96
|
+
const members = Array.isArray(operand) ? operand : [];
|
|
97
|
+
if (!members.some((member) => sameJson(member, value))) {
|
|
98
|
+
problems.push({ path, message: `${show(value)} is not one of ${members.map(show).join(", ")}` });
|
|
99
|
+
}
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
case "const": {
|
|
103
|
+
if (!sameJson(operand, value)) {
|
|
104
|
+
problems.push({ path, message: `${show(value)} is not ${show(operand)}` });
|
|
105
|
+
}
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
case "properties": {
|
|
109
|
+
operandMust(isObject(operand), path, keyword, "an object");
|
|
110
|
+
if (!isObject(operand) || !isObject(value)) {
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
for (const [key, subschema] of Object.entries(operand)) {
|
|
114
|
+
if (Object.hasOwn(value, key)) {
|
|
115
|
+
check(subschema, value[key], child(path, key), problems);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
case "required": {
|
|
121
|
+
operandMust(Array.isArray(operand) && operand.every((key) => typeof key === "string"), path, keyword, "string[]");
|
|
122
|
+
if (!Array.isArray(operand) || !isObject(value)) {
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
for (const key of operand) {
|
|
126
|
+
if (!Object.hasOwn(value, String(key))) {
|
|
127
|
+
problems.push({ path, message: `missing required key ${show(key)}` });
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
case "additionalProperties": {
|
|
133
|
+
if (!isObject(value)) {
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
const declared = isObject(schema.properties) ? schema.properties : {};
|
|
137
|
+
for (const [key, member] of Object.entries(value)) {
|
|
138
|
+
if (Object.hasOwn(declared, key)) {
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
if (operand === false) {
|
|
142
|
+
problems.push({ path, message: `unknown key ${show(key)}` });
|
|
143
|
+
}
|
|
144
|
+
else if (operand !== true) {
|
|
145
|
+
check(operand, member, child(path, key), problems);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
case "items": {
|
|
151
|
+
if (Array.isArray(value)) {
|
|
152
|
+
value.forEach((element, index) => check(operand, element, `${path}[${index}]`, problems));
|
|
153
|
+
}
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
case "uniqueItems": {
|
|
157
|
+
if (operand !== true || !Array.isArray(value)) {
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
const seen = new Set();
|
|
161
|
+
for (const element of value) {
|
|
162
|
+
const text = JSON.stringify(element);
|
|
163
|
+
if (seen.has(text)) {
|
|
164
|
+
problems.push({ path, message: `duplicate item ${text}` });
|
|
165
|
+
}
|
|
166
|
+
seen.add(text);
|
|
167
|
+
}
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
case "minItems": {
|
|
171
|
+
operandMust(typeof operand === "number", path, keyword, "a number");
|
|
172
|
+
if (Array.isArray(value) && typeof operand === "number" && value.length < operand) {
|
|
173
|
+
const noun = value.length === 1 ? "item" : "items";
|
|
174
|
+
problems.push({ path, message: `has ${value.length} ${noun}, needs at least ${operand}` });
|
|
175
|
+
}
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
case "minLength": {
|
|
179
|
+
operandMust(typeof operand === "number", path, keyword, "a number");
|
|
180
|
+
if (typeof value !== "string" || typeof operand !== "number" || value.length >= operand) {
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
const message = operand === 1 ? "must not be empty" : `${show(value)} is shorter than ${operand} characters`;
|
|
184
|
+
problems.push({ path, message });
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
case "pattern": {
|
|
188
|
+
operandMust(typeof operand === "string", path, keyword, "a string");
|
|
189
|
+
if (typeof value === "string" && typeof operand === "string" && !new RegExp(operand).test(value)) {
|
|
190
|
+
problems.push({ path, message: `${show(value)} does not match /${operand}/` });
|
|
191
|
+
}
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
// Enforced, not annotative: `regex` compiles with `RegExp` and refuses a construct POSIX ERE lacks,
|
|
195
|
+
// because `grep -E` runs the pattern and BSD and GNU grep read those differently.
|
|
196
|
+
case "format": {
|
|
197
|
+
operandMust(operand === "regex", path, keyword, '"regex", the one format enforced here');
|
|
198
|
+
if (typeof value !== "string") {
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
const construct = notEre(value);
|
|
202
|
+
if (compile(value) === undefined) {
|
|
203
|
+
problems.push({ path, message: `${show(value)} is not a valid regular expression` });
|
|
204
|
+
}
|
|
205
|
+
else if (construct !== undefined) {
|
|
206
|
+
problems.push({
|
|
207
|
+
path,
|
|
208
|
+
message: `${show(value)} uses ${construct}, which POSIX ERE lacks; grep -E runs the pattern`
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
case "minimum": {
|
|
214
|
+
operandMust(typeof operand === "number", path, keyword, "a number");
|
|
215
|
+
if (typeof value === "number" && typeof operand === "number" && value < operand) {
|
|
216
|
+
problems.push({ path, message: `${show(value)} is below the minimum ${operand}` });
|
|
217
|
+
}
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
default: {
|
|
221
|
+
if (!ANNOTATIONS.has(keyword)) {
|
|
222
|
+
throw new SchemaError(`${at(path)}: unsupported keyword ${show(keyword)}`);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export type Heading = {
|
|
2
|
+
level: number;
|
|
3
|
+
text: string;
|
|
4
|
+
anchor: string;
|
|
5
|
+
line: number;
|
|
6
|
+
};
|
|
7
|
+
export type Row = {
|
|
8
|
+
cells: string[];
|
|
9
|
+
line: number;
|
|
10
|
+
};
|
|
11
|
+
export type Link = {
|
|
12
|
+
target: string;
|
|
13
|
+
line: number;
|
|
14
|
+
};
|
|
15
|
+
/** Every ATX heading in `text`, a repeated anchor numbered `-1`, `-2` as GitHub numbers it. */
|
|
16
|
+
export declare function headings(text: string): Heading[];
|
|
17
|
+
/**
|
|
18
|
+
* The body rows of the tables in the section under the heading whose text is `title`, up to the next
|
|
19
|
+
* heading of its level or above; empty when there is no such heading.
|
|
20
|
+
*/
|
|
21
|
+
export declare function tableRows(text: string, title: string): Row[];
|
|
22
|
+
/** Every backticked span in `text`, without its backticks. */
|
|
23
|
+
export declare function codeSpans(text: string): string[];
|
|
24
|
+
/** `text` with each fenced line and each code span blanked to spaces, so every offset and line is kept. */
|
|
25
|
+
export declare function prose(text: string): string;
|
|
26
|
+
/** Every link target in the prose of `text`, its angle brackets dropped, with its 1-based line. */
|
|
27
|
+
export declare function links(text: string): Link[];
|
|
28
|
+
/** The anchors of each markdown file read so far, by path. */
|
|
29
|
+
export type Anchors = Map<string, Set<string>>;
|
|
30
|
+
/** The anchors of the headings of `path` under `root`; none when it is not a file. */
|
|
31
|
+
export declare function anchorsOf(root: string, path: string, cache: Anchors): Set<string>;
|