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,175 @@
|
|
|
1
|
+
// `dw-sdlc gate`: the Gate of a repository, from the `gate` key of its resolved agentic config.
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
import { parseArgs } from "node:util";
|
|
4
|
+
import { repositoryRoot, resolveConfig } from "../agentic-config.js";
|
|
5
|
+
import { CannotRun } from "../checks/check.js";
|
|
6
|
+
import { enabledChecks } from "../checks/index.js";
|
|
7
|
+
import { annotation, failureJson, failureLine } from "../report.js";
|
|
8
|
+
export const summary = "run the Gate: the Steps of gate.steps in order, then every Check under gate.checks";
|
|
9
|
+
const USAGE = `Usage: dw-sdlc gate [--json] [--root <dir>]
|
|
10
|
+
|
|
11
|
+
Runs the Gate of the repository holding --root (default: the current directory): each Step of
|
|
12
|
+
gate.steps in order from the repository root, its output passed through, stopping at the first that
|
|
13
|
+
fails; then every Check under gate.checks, the run scripts and then the Checks the package ships,
|
|
14
|
+
none stopping the next. Prints one line per failure: the file where there is one, the rule, the
|
|
15
|
+
remedy; under GitHub Actions each line is also a workflow annotation. --json prints one document
|
|
16
|
+
instead and sends the commands' own output to stderr.
|
|
17
|
+
|
|
18
|
+
Exit 0 when everything passes, 1 when a Step or a Check fails, 2 when the Gate cannot run: an
|
|
19
|
+
invalid config layer, a Step or Check whose command the shell cannot find or execute, or a Check
|
|
20
|
+
missing an input it reads.`;
|
|
21
|
+
const EXIT_CODES = { pass: 0, fail: 1, error: 2 };
|
|
22
|
+
/** What the shell answers for a command it cannot find, and for one it cannot execute. */
|
|
23
|
+
const NOT_RUNNABLE = { 127: "was not found", 126: "could not be executed" };
|
|
24
|
+
const SKIPPED = { status: "skipped", exitCode: null };
|
|
25
|
+
const REMEDIES = {
|
|
26
|
+
invalidLayer: "fix the file; the Gate runs once every layer is valid",
|
|
27
|
+
failed: "fix what it printed above",
|
|
28
|
+
stepNotRunnable: "install the command or fix gate.steps in the agentic config",
|
|
29
|
+
checkNotRunnable: "install the command or fix gate.checks.run in the agentic config",
|
|
30
|
+
checkStopped: "fix what stopped it, or report it to dw-sdlc"
|
|
31
|
+
};
|
|
32
|
+
function plural(count, noun) {
|
|
33
|
+
return `${count} ${noun}${count === 1 ? "" : "s"}`;
|
|
34
|
+
}
|
|
35
|
+
function parseCli(argv) {
|
|
36
|
+
try {
|
|
37
|
+
const { values } = parseArgs({
|
|
38
|
+
args: argv,
|
|
39
|
+
options: { help: { type: "boolean", short: "h" }, json: { type: "boolean" }, root: { type: "string" } }
|
|
40
|
+
});
|
|
41
|
+
return { help: values.help === true, json: values.json === true, root: values.root };
|
|
42
|
+
}
|
|
43
|
+
catch (error) {
|
|
44
|
+
return { error: error instanceof Error ? error.message : String(error) };
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
function configFailure({ file, path, message }) {
|
|
48
|
+
const where = path === "" ? message : `${path}: ${message}`;
|
|
49
|
+
return { file, rule: "config", message: where, remedy: REMEDIES.invalidLayer };
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Runs `command` through `sh -c` from `root`, stdin closed and stderr passed through; stdout too, or
|
|
53
|
+
* onto stderr when `--json` keeps stdout for the document. `detail` says what went wrong, when it did.
|
|
54
|
+
*/
|
|
55
|
+
function runCommand(command, root, json) {
|
|
56
|
+
const result = spawnSync(command, { shell: true, cwd: root, stdio: ["ignore", json ? 2 : "inherit", "inherit"] });
|
|
57
|
+
if (result.error !== undefined) {
|
|
58
|
+
return { status: "error", exitCode: null, detail: `could not start: ${result.error.message}` };
|
|
59
|
+
}
|
|
60
|
+
if (result.status === 0) {
|
|
61
|
+
return { status: "pass", exitCode: 0 };
|
|
62
|
+
}
|
|
63
|
+
if (result.status === null) {
|
|
64
|
+
return { status: "fail", exitCode: null, detail: `was stopped by ${result.signal ?? "a signal"}` };
|
|
65
|
+
}
|
|
66
|
+
const notRunnable = NOT_RUNNABLE[result.status];
|
|
67
|
+
if (notRunnable !== undefined) {
|
|
68
|
+
return { status: "error", exitCode: result.status, detail: `${notRunnable} (exit ${result.status})` };
|
|
69
|
+
}
|
|
70
|
+
return { status: "fail", exitCode: result.status, detail: `exited ${result.status}` };
|
|
71
|
+
}
|
|
72
|
+
/** Runs one Check the package ships, its failures named by the Check; one it cannot judge is an error. */
|
|
73
|
+
function runShipped(check, root) {
|
|
74
|
+
try {
|
|
75
|
+
const failures = check.run(root).map((finding) => ({ rule: check.name, ...finding }));
|
|
76
|
+
const status = failures.length === 0 ? "pass" : "fail";
|
|
77
|
+
return { status, exitCode: EXIT_CODES[status], failures };
|
|
78
|
+
}
|
|
79
|
+
catch (error) {
|
|
80
|
+
const finding = error instanceof CannotRun
|
|
81
|
+
? error.finding
|
|
82
|
+
: {
|
|
83
|
+
message: `stopped: ${error instanceof Error ? error.message : String(error)}`,
|
|
84
|
+
remedy: REMEDIES.checkStopped
|
|
85
|
+
};
|
|
86
|
+
return { status: "error", exitCode: EXIT_CODES.error, failures: [{ rule: check.name, ...finding }] };
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
function worst(outcomes) {
|
|
90
|
+
const statuses = new Set(outcomes.map(({ status }) => status));
|
|
91
|
+
return statuses.has("error") ? "error" : statuses.has("fail") ? "fail" : "pass";
|
|
92
|
+
}
|
|
93
|
+
function runGate(root, json) {
|
|
94
|
+
const log = json ? process.stderr : process.stdout;
|
|
95
|
+
const { config, problems } = resolveConfig(root);
|
|
96
|
+
const gateCommand = config.gate.command ?? null;
|
|
97
|
+
if (problems.length > 0) {
|
|
98
|
+
return { status: "error", command: gateCommand, steps: [], checks: [], failures: problems.map(configFailure) };
|
|
99
|
+
}
|
|
100
|
+
const failures = [];
|
|
101
|
+
/** Runs one Step or Check and records its failure under `rule`, if it has one. */
|
|
102
|
+
const runOne = (command, rule, notRunnable) => {
|
|
103
|
+
const { detail, ...outcome } = runCommand(command, root, json);
|
|
104
|
+
if (detail !== undefined) {
|
|
105
|
+
const remedy = outcome.status === "error" ? notRunnable : REMEDIES.failed;
|
|
106
|
+
failures.push({ rule, message: `${JSON.stringify(command)} ${detail}`, remedy });
|
|
107
|
+
}
|
|
108
|
+
return outcome;
|
|
109
|
+
};
|
|
110
|
+
const steps = [];
|
|
111
|
+
let stopped = false;
|
|
112
|
+
for (const step of config.gate.steps) {
|
|
113
|
+
if (stopped) {
|
|
114
|
+
steps.push({ command: step, ...SKIPPED });
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
log.write(`gate: step ${step}\n`);
|
|
118
|
+
const outcome = runOne(step, "step", REMEDIES.stepNotRunnable);
|
|
119
|
+
steps.push({ command: step, ...outcome });
|
|
120
|
+
stopped = outcome.status !== "pass";
|
|
121
|
+
}
|
|
122
|
+
const checks = [];
|
|
123
|
+
for (const check of config.gate.checks.run) {
|
|
124
|
+
if (stopped) {
|
|
125
|
+
checks.push({ ...check, ...SKIPPED });
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
log.write(`gate: check ${check.name}\n`);
|
|
129
|
+
checks.push({ ...check, ...runOne(check.command, check.name, REMEDIES.checkNotRunnable) });
|
|
130
|
+
}
|
|
131
|
+
for (const check of enabledChecks(config.gate.checks)) {
|
|
132
|
+
if (stopped) {
|
|
133
|
+
checks.push({ name: check.name, command: null, ...SKIPPED });
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
log.write(`gate: check ${check.name}\n`);
|
|
137
|
+
const { failures: found, ...outcome } = runShipped(check, root);
|
|
138
|
+
failures.push(...found);
|
|
139
|
+
checks.push({ name: check.name, command: null, ...outcome });
|
|
140
|
+
}
|
|
141
|
+
return { status: worst([...steps, ...checks]), command: gateCommand, steps, checks, failures };
|
|
142
|
+
}
|
|
143
|
+
function verdict(report) {
|
|
144
|
+
if (report.status === "pass") {
|
|
145
|
+
return `gate: green — ${plural(report.steps.length, "step")}, ${plural(report.checks.length, "check")}`;
|
|
146
|
+
}
|
|
147
|
+
const failures = plural(report.failures.length, "failure");
|
|
148
|
+
return report.status === "fail" ? `gate: failed — ${failures}` : `gate: cannot run — ${failures}`;
|
|
149
|
+
}
|
|
150
|
+
export function run(argv) {
|
|
151
|
+
const cli = parseCli(argv);
|
|
152
|
+
if ("error" in cli) {
|
|
153
|
+
process.stderr.write(`dw-sdlc gate: ${cli.error}\n\n${USAGE}\n`);
|
|
154
|
+
return 2;
|
|
155
|
+
}
|
|
156
|
+
if (cli.help) {
|
|
157
|
+
process.stdout.write(`${USAGE}\n`);
|
|
158
|
+
return 0;
|
|
159
|
+
}
|
|
160
|
+
const report = runGate(repositoryRoot(cli.root ?? process.cwd()), cli.json);
|
|
161
|
+
const actions = process.env.GITHUB_ACTIONS === "true";
|
|
162
|
+
if (cli.json) {
|
|
163
|
+
const document = { ...report, failures: report.failures.map(failureJson) };
|
|
164
|
+
process.stdout.write(`${JSON.stringify(document, null, 2)}\n`);
|
|
165
|
+
// The runner reads workflow commands from stderr as from stdout, so stdout stays one document.
|
|
166
|
+
for (const failure of actions ? report.failures : []) {
|
|
167
|
+
process.stderr.write(`${annotation(failure)}\n`);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
else {
|
|
171
|
+
const lines = report.failures.flatMap((failure) => actions ? [failureLine(failure), annotation(failure)] : [failureLine(failure)]);
|
|
172
|
+
process.stdout.write(`${[...lines, verdict(report)].join("\n")}\n`);
|
|
173
|
+
}
|
|
174
|
+
return EXIT_CODES[report.status];
|
|
175
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// `dw-sdlc primitives --paths <files>`: for each path, the Primitives of the catalogue that name it,
|
|
2
|
+
// with their summaries and the documents to read, or that none does.
|
|
3
|
+
import { existsSync, realpathSync } from "node:fs";
|
|
4
|
+
import { basename, dirname, join, relative, resolve } from "node:path";
|
|
5
|
+
import { parseArgs } from "node:util";
|
|
6
|
+
import { repositoryRoot, resolveConfig } from "../agentic-config.js";
|
|
7
|
+
import { primitivesNaming, readCatalogue } from "../catalogue.js";
|
|
8
|
+
import { catalogueFile } from "../checks/primitives.js";
|
|
9
|
+
export const summary = "name the Primitives that own each path, with their summaries and documents";
|
|
10
|
+
const USAGE = `Usage: dw-sdlc primitives --paths <file>... [--json] [--root <dir>]
|
|
11
|
+
|
|
12
|
+
Prints, for each path, the Primitives of the catalogue whose code names it, the file itself or the
|
|
13
|
+
nearest directory above it: the id, the name, the summary and the documents to read; or that no
|
|
14
|
+
Primitive names it. A path is read from the current directory. The catalogue is the one the
|
|
15
|
+
primitives Check reads, in the repository holding --root (default: the current directory). --json
|
|
16
|
+
prints one document instead.
|
|
17
|
+
|
|
18
|
+
Exit 0 with an answer for every path, 2 when there is no catalogue to read or it is not one.`;
|
|
19
|
+
const REMEDIES = {
|
|
20
|
+
missing: "create it, or point gate.checks.primitives.file at the catalogue",
|
|
21
|
+
invalid: "fix the catalogue; dw-sdlc gate names each problem under the primitives Check"
|
|
22
|
+
};
|
|
23
|
+
function parseCli(argv) {
|
|
24
|
+
try {
|
|
25
|
+
const { values, positionals } = parseArgs({
|
|
26
|
+
args: argv,
|
|
27
|
+
allowPositionals: true,
|
|
28
|
+
options: {
|
|
29
|
+
help: { type: "boolean", short: "h" },
|
|
30
|
+
json: { type: "boolean" },
|
|
31
|
+
paths: { type: "boolean" },
|
|
32
|
+
root: { type: "string" }
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
const cli = {
|
|
36
|
+
help: values.help === true,
|
|
37
|
+
json: values.json === true,
|
|
38
|
+
paths: values.paths === true,
|
|
39
|
+
files: positionals,
|
|
40
|
+
root: values.root
|
|
41
|
+
};
|
|
42
|
+
return cli.help || cli.paths ? cli : { error: "name the paths with --paths" };
|
|
43
|
+
}
|
|
44
|
+
catch (error) {
|
|
45
|
+
return { error: error instanceof Error ? error.message : String(error) };
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* `path` with every symbolic link in it resolved, as git reports the root: the nearest part on disk
|
|
50
|
+
* resolved and the rest kept, so a deleted file still maps into the repository.
|
|
51
|
+
*/
|
|
52
|
+
function realPath(path) {
|
|
53
|
+
if (existsSync(path)) {
|
|
54
|
+
return realpathSync(path);
|
|
55
|
+
}
|
|
56
|
+
const parent = dirname(path);
|
|
57
|
+
return parent === path ? path : join(realPath(parent), basename(path));
|
|
58
|
+
}
|
|
59
|
+
function brief({ id, name, summary: what, docs }) {
|
|
60
|
+
return { id, name, summary: what, docs };
|
|
61
|
+
}
|
|
62
|
+
function plain(path, primitives) {
|
|
63
|
+
if (primitives.length === 0) {
|
|
64
|
+
return [`${path}: no Primitive names this path`];
|
|
65
|
+
}
|
|
66
|
+
return primitives.flatMap(({ id, name, summary: what, docs }) => [
|
|
67
|
+
`${path}: ${id} (${name}) — ${what}`,
|
|
68
|
+
` read: ${docs.join(", ")}`
|
|
69
|
+
]);
|
|
70
|
+
}
|
|
71
|
+
export function run(argv) {
|
|
72
|
+
const cli = parseCli(argv);
|
|
73
|
+
if ("error" in cli) {
|
|
74
|
+
process.stderr.write(`dw-sdlc primitives: ${cli.error}\n\n${USAGE}\n`);
|
|
75
|
+
return 2;
|
|
76
|
+
}
|
|
77
|
+
if (cli.help) {
|
|
78
|
+
process.stdout.write(`${USAGE}\n`);
|
|
79
|
+
return 0;
|
|
80
|
+
}
|
|
81
|
+
const root = repositoryRoot(cli.root ?? process.cwd());
|
|
82
|
+
const file = catalogueFile(resolveConfig(root).config.gate.checks);
|
|
83
|
+
const read = readCatalogue(root, file);
|
|
84
|
+
if (read.state !== "valid") {
|
|
85
|
+
const problems = read.state === "missing"
|
|
86
|
+
? [`the catalogue does not exist — ${REMEDIES.missing}`]
|
|
87
|
+
: read.problems.map((problem) => `${problem} — ${REMEDIES.invalid}`);
|
|
88
|
+
process.stderr.write(problems.map((problem) => `dw-sdlc primitives: ${file}: ${problem}\n`).join(""));
|
|
89
|
+
return 2;
|
|
90
|
+
}
|
|
91
|
+
const answers = cli.files.map((path) => {
|
|
92
|
+
const fromRoot = relative(root, realPath(resolve(path)));
|
|
93
|
+
return { path, primitives: primitivesNaming(read.catalogue, fromRoot) };
|
|
94
|
+
});
|
|
95
|
+
if (cli.json) {
|
|
96
|
+
const paths = answers.map(({ path, primitives }) => ({ path, primitives: primitives.map(brief) }));
|
|
97
|
+
process.stdout.write(`${JSON.stringify({ paths }, null, 2)}\n`);
|
|
98
|
+
}
|
|
99
|
+
else {
|
|
100
|
+
process.stdout.write(answers
|
|
101
|
+
.flatMap(({ path, primitives }) => plain(path, primitives))
|
|
102
|
+
.map((line) => `${line}\n`)
|
|
103
|
+
.join(""));
|
|
104
|
+
}
|
|
105
|
+
return 0;
|
|
106
|
+
}
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
// `dw-sdlc references setup` sets a repository up to declare References. Five files, each written only
|
|
2
|
+
// when it would change, so a repeat with nothing changed writes nothing:
|
|
3
|
+
//
|
|
4
|
+
// references/AGENTS.md the Router, from templates/router.md when there is none; on every run the
|
|
5
|
+
// block between its two markers, a table of each Reference's dir and when
|
|
6
|
+
// from the key, while the text around the block stays the author's
|
|
7
|
+
// references/CLAUDE.md a symlink to AGENTS.md, so Claude Code loads the Router; written once
|
|
8
|
+
// .claude/settings.json claudeMdExcludes for the clones' own CLAUDE.md and AGENTS.md
|
|
9
|
+
// .gitignore references/* and the two exceptions that keep the Router tracked
|
|
10
|
+
// .ai/agentic.config.json an empty references key when the config has none
|
|
11
|
+
//
|
|
12
|
+
// Existing content stays: a list gains the entries it lacks at its end, and nothing is removed or
|
|
13
|
+
// reordered. A settings file or a config that is not a JSON object is refused, not rewritten, and so
|
|
14
|
+
// is an ignore file that ignores references/ itself and a Router with one marker and not the other,
|
|
15
|
+
// or whose key fails the parser. --check writes nothing and exits 1 when any file would change.
|
|
16
|
+
import { spawnSync } from "node:child_process";
|
|
17
|
+
import { existsSync, lstatSync, mkdirSync, readFileSync, symlinkSync, writeFileSync } from "node:fs";
|
|
18
|
+
import { dirname, join } from "node:path";
|
|
19
|
+
import { parseArgs } from "node:util";
|
|
20
|
+
import { CONFIG, isObject, readReferences } from "../references-key.js";
|
|
21
|
+
import { renderBlock, withBlock } from "../router-block.js";
|
|
22
|
+
const ROUTER = "references/AGENTS.md";
|
|
23
|
+
const ROUTER_LINK = "references/CLAUDE.md";
|
|
24
|
+
const SETTINGS = ".claude/settings.json";
|
|
25
|
+
const IGNORE = ".gitignore";
|
|
26
|
+
const TEMPLATE = join(import.meta.dirname, "..", "..", "templates", "router.md");
|
|
27
|
+
/** A clone's own agent files at any depth; Claude Code matches the globs against absolute paths. */
|
|
28
|
+
const EXCLUDES = [
|
|
29
|
+
"**/references/*/CLAUDE.md",
|
|
30
|
+
"**/references/*/**/CLAUDE.md",
|
|
31
|
+
"**/references/*/AGENTS.md",
|
|
32
|
+
"**/references/*/**/AGENTS.md"
|
|
33
|
+
];
|
|
34
|
+
/** Every clone ignored, then the Router and its symlink tracked: an exception counts after its pattern. */
|
|
35
|
+
const IGNORED = "references/*";
|
|
36
|
+
const TRACKED = ["!references/AGENTS.md", "!references/CLAUDE.md"];
|
|
37
|
+
/** Lines that ignore references/ itself: git never looks inside it, so no exception tracks the Router. */
|
|
38
|
+
const WHOLE = new Set(["references", "references/", "/references", "/references/"]);
|
|
39
|
+
const IGNORE_COMMENT = "# References are cloned on each machine by the references plugin; only the Router is tracked.";
|
|
40
|
+
export const summary = "set a repository up to declare References: the Router, its table and the ignore rules";
|
|
41
|
+
const USAGE = `Usage: dw-sdlc references setup [--root <dir>] [--dry-run | --check]
|
|
42
|
+
or: pnpm references:setup with the same flags, in a checkout of dw-sdlc
|
|
43
|
+
|
|
44
|
+
Sets the repository at --root, or the one the current directory sits in, up to declare References:
|
|
45
|
+
the Router in ${ROUTER} with its CLAUDE.md symlink and, between its two markers, the table of each
|
|
46
|
+
Reference's dir and when from the references key, claudeMdExcludes in ${SETTINGS} for the clones'
|
|
47
|
+
own agent files, the ${IGNORE} lines that keep every clone out of git but the Router, and an empty
|
|
48
|
+
references key in ${CONFIG}. Every file is written only when it would change, and the text around
|
|
49
|
+
the Router's table never; --dry-run reports without writing, and --check does the same and exits 1
|
|
50
|
+
when a file would change. Prints one line per file. Exit 1 when a file is refused, 2 on a bad
|
|
51
|
+
argument.`;
|
|
52
|
+
function messageOf(error) {
|
|
53
|
+
return error instanceof Error ? error.message : String(error);
|
|
54
|
+
}
|
|
55
|
+
function fail(message) {
|
|
56
|
+
console.error(`references:setup: ${message}`);
|
|
57
|
+
process.exit(2);
|
|
58
|
+
}
|
|
59
|
+
function render(value) {
|
|
60
|
+
return `${JSON.stringify(value, null, 2)}\n`;
|
|
61
|
+
}
|
|
62
|
+
/** The JSON object `text` holds, or undefined when it holds anything else. */
|
|
63
|
+
function parseObject(text) {
|
|
64
|
+
let parsed;
|
|
65
|
+
try {
|
|
66
|
+
parsed = JSON.parse(text);
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
return undefined;
|
|
70
|
+
}
|
|
71
|
+
return isObject(parsed) ? parsed : undefined;
|
|
72
|
+
}
|
|
73
|
+
class Writer {
|
|
74
|
+
steps = [];
|
|
75
|
+
root;
|
|
76
|
+
dryRun;
|
|
77
|
+
constructor(root, dryRun) {
|
|
78
|
+
this.root = root;
|
|
79
|
+
this.dryRun = dryRun;
|
|
80
|
+
}
|
|
81
|
+
record(file, changed, write) {
|
|
82
|
+
if (changed && !this.dryRun) {
|
|
83
|
+
const path = join(this.root, file);
|
|
84
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
85
|
+
write(path);
|
|
86
|
+
}
|
|
87
|
+
this.steps.push({ file, outcome: changed ? "wrote" : "unchanged" });
|
|
88
|
+
}
|
|
89
|
+
/** The text `change` gives `file`, from what `file` holds now; written when the two differ. */
|
|
90
|
+
put(file, change) {
|
|
91
|
+
const path = join(this.root, file);
|
|
92
|
+
const current = existsSync(path) ? readFileSync(path, "utf8") : undefined;
|
|
93
|
+
const next = change(current);
|
|
94
|
+
if ("refusal" in next) {
|
|
95
|
+
this.steps.push({ file, outcome: "refused", detail: next.refusal });
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
this.record(file, next.text !== current, (target) => {
|
|
99
|
+
writeFileSync(target, next.text);
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
/** `file`, made by `make` when nothing sits at its path, a dangling symlink included; never replaced. */
|
|
103
|
+
create(file, make) {
|
|
104
|
+
this.record(file, lstatSync(join(this.root, file), { throwIfNoEntry: false }) === undefined, make);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
/** The settings with claudeMdExcludes holding every glob of EXCLUDES, those it lacks appended. */
|
|
108
|
+
function withExcludes(current) {
|
|
109
|
+
if (current === undefined) {
|
|
110
|
+
return { text: render({ claudeMdExcludes: EXCLUDES }) };
|
|
111
|
+
}
|
|
112
|
+
const settings = parseObject(current);
|
|
113
|
+
if (settings === undefined) {
|
|
114
|
+
return { refusal: "is not a JSON object; fix it by hand first" };
|
|
115
|
+
}
|
|
116
|
+
const value = settings.claudeMdExcludes ?? [];
|
|
117
|
+
if (!Array.isArray(value)) {
|
|
118
|
+
return { refusal: "claudeMdExcludes is not a list; fix it by hand first" };
|
|
119
|
+
}
|
|
120
|
+
const existing = value;
|
|
121
|
+
const missing = EXCLUDES.filter((glob) => !existing.includes(glob));
|
|
122
|
+
return { text: missing.length === 0 ? current : render({ ...settings, claudeMdExcludes: [...existing, ...missing] }) };
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* The ignore file with the lines it lacks appended. Without `references/*` all three go in, so each
|
|
126
|
+
* exception lands after the pattern it excepts; with it, only the exceptions it lacks.
|
|
127
|
+
*/
|
|
128
|
+
function withIgnoreLines(current) {
|
|
129
|
+
const text = current ?? "";
|
|
130
|
+
const lines = text.split(/\r?\n/).map((line) => line.trimEnd());
|
|
131
|
+
if (lines.some((line) => WHOLE.has(line))) {
|
|
132
|
+
return { refusal: `ignores references/ itself, so no exception tracks the Router; ignore ${IGNORED} instead` };
|
|
133
|
+
}
|
|
134
|
+
const ignored = lines.includes(IGNORED);
|
|
135
|
+
const missing = ignored ? TRACKED.filter((line) => !lines.includes(line)) : [IGNORE_COMMENT, IGNORED, ...TRACKED];
|
|
136
|
+
if (missing.length === 0) {
|
|
137
|
+
return { text };
|
|
138
|
+
}
|
|
139
|
+
const head = text === "" || text.endsWith("\n") ? text : `${text}\n`;
|
|
140
|
+
const gap = head !== "" && !ignored ? "\n" : "";
|
|
141
|
+
return { text: `${head}${gap}${missing.join("\n")}\n` };
|
|
142
|
+
}
|
|
143
|
+
/** The config with an empty `references` key when it has none, inserted so its own layout stays. */
|
|
144
|
+
function withEmptyKey(current) {
|
|
145
|
+
if (current === undefined) {
|
|
146
|
+
return { text: render({ references: [] }) };
|
|
147
|
+
}
|
|
148
|
+
const config = parseObject(current);
|
|
149
|
+
if (config === undefined) {
|
|
150
|
+
return { refusal: "is not a JSON object; fix it by hand first" };
|
|
151
|
+
}
|
|
152
|
+
if (Object.hasOwn(config, "references")) {
|
|
153
|
+
return { text: current };
|
|
154
|
+
}
|
|
155
|
+
const close = current.lastIndexOf("}");
|
|
156
|
+
const body = current.slice(0, close).trimEnd();
|
|
157
|
+
const comma = body.endsWith("{") ? "" : ",";
|
|
158
|
+
return { text: `${body}${comma}\n "references": []\n${current.slice(close)}` };
|
|
159
|
+
}
|
|
160
|
+
/** The repository `dir` sits in, or `dir` itself outside one. */
|
|
161
|
+
function toplevel(dir) {
|
|
162
|
+
const result = spawnSync("git", ["-C", dir, "rev-parse", "--show-toplevel"], { encoding: "utf8" });
|
|
163
|
+
return result.status === 0 ? result.stdout.trim() : dir;
|
|
164
|
+
}
|
|
165
|
+
function setup(root, dryRun) {
|
|
166
|
+
const writer = new Writer(root, dryRun);
|
|
167
|
+
const template = readFileSync(TEMPLATE, "utf8");
|
|
168
|
+
const reading = readReferences(root);
|
|
169
|
+
writer.put(ROUTER, (current) => {
|
|
170
|
+
if ("problem" in reading) {
|
|
171
|
+
return { refusal: `${reading.problem}; the table comes from the key, so fix it first` };
|
|
172
|
+
}
|
|
173
|
+
const next = withBlock(current ?? template, renderBlock(reading.references ?? []));
|
|
174
|
+
return "problem" in next ? { refusal: next.problem } : next;
|
|
175
|
+
});
|
|
176
|
+
writer.create(ROUTER_LINK, (path) => {
|
|
177
|
+
symlinkSync("AGENTS.md", path);
|
|
178
|
+
});
|
|
179
|
+
writer.put(SETTINGS, withExcludes);
|
|
180
|
+
writer.put(IGNORE, withIgnoreLines);
|
|
181
|
+
writer.put(CONFIG, withEmptyKey);
|
|
182
|
+
return writer.steps;
|
|
183
|
+
}
|
|
184
|
+
export function run(argv) {
|
|
185
|
+
let values;
|
|
186
|
+
try {
|
|
187
|
+
values = parseArgs({
|
|
188
|
+
args: argv,
|
|
189
|
+
options: {
|
|
190
|
+
help: { type: "boolean", short: "h" },
|
|
191
|
+
root: { type: "string" },
|
|
192
|
+
"dry-run": { type: "boolean" },
|
|
193
|
+
check: { type: "boolean" }
|
|
194
|
+
}
|
|
195
|
+
}).values;
|
|
196
|
+
}
|
|
197
|
+
catch (error) {
|
|
198
|
+
fail(`${messageOf(error)}\n\n${USAGE}`);
|
|
199
|
+
}
|
|
200
|
+
if (values.help === true) {
|
|
201
|
+
console.log(USAGE);
|
|
202
|
+
return 0;
|
|
203
|
+
}
|
|
204
|
+
const check = values.check === true;
|
|
205
|
+
const dryRun = values["dry-run"] === true || check;
|
|
206
|
+
const steps = setup(toplevel(values.root ?? process.cwd()), dryRun);
|
|
207
|
+
for (const step of steps) {
|
|
208
|
+
const verb = step.outcome === "wrote" && dryRun ? "would write" : step.outcome;
|
|
209
|
+
console.log(`${verb.padEnd(11)} ${step.file}${step.detail === undefined ? "" : ` — ${step.detail}`}`);
|
|
210
|
+
}
|
|
211
|
+
const refused = steps.some((step) => step.outcome === "refused");
|
|
212
|
+
return refused || (check && steps.some((step) => step.outcome === "wrote")) ? 1 : 0;
|
|
213
|
+
}
|