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