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
package/dist/markdown.js
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// The parts of a markdown file the Checks read: its ATX headings with the anchors GitHub gives them,
|
|
2
|
+
// the rows of a table under a heading, its links, and its prose with the code masked; and the anchors
|
|
3
|
+
// of a file on disk. A line inside a fenced code block is never any of them.
|
|
4
|
+
import { readFileSync, statSync } from "node:fs";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
/** A code fence at any indent, so one inside a list item counts. */
|
|
7
|
+
const FENCE = /^[ \t]*(`{3,}|~{3,})/;
|
|
8
|
+
const ATX = /^ {0,3}(#{1,6})[ \t]+(.*?)(?:[ \t]+#+)?[ \t]*$/;
|
|
9
|
+
/** Each line with its 1-based number, `fenced` when a code fence opens, closes or encloses it. */
|
|
10
|
+
function scan(text) {
|
|
11
|
+
let fence;
|
|
12
|
+
return text.split("\n").map((line, index) => {
|
|
13
|
+
const marker = FENCE.exec(line)?.[1];
|
|
14
|
+
if (fence === undefined && marker !== undefined) {
|
|
15
|
+
fence = marker;
|
|
16
|
+
return { text: line, line: index + 1, fenced: true };
|
|
17
|
+
}
|
|
18
|
+
if (fence !== undefined && marker !== undefined && marker[0] === fence[0] && marker.length >= fence.length) {
|
|
19
|
+
fence = undefined;
|
|
20
|
+
return { text: line, line: index + 1, fenced: true };
|
|
21
|
+
}
|
|
22
|
+
return { text: line, line: index + 1, fenced: fence !== undefined };
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* GitHub's anchor for a heading: its text as rendered, lowercased, every character but a letter, a
|
|
27
|
+
* digit, a space, `-` and `_` dropped, each space a hyphen.
|
|
28
|
+
*/
|
|
29
|
+
function slug(text) {
|
|
30
|
+
const rendered = text.replace(/!?\[([^\]]*)\]\([^)]*\)/g, "$1");
|
|
31
|
+
return rendered
|
|
32
|
+
.toLowerCase()
|
|
33
|
+
.replace(/[^\p{L}\p{M}\p{N}\p{Pc} -]/gu, "")
|
|
34
|
+
.replaceAll(" ", "-");
|
|
35
|
+
}
|
|
36
|
+
/** Every ATX heading in `text`, a repeated anchor numbered `-1`, `-2` as GitHub numbers it. */
|
|
37
|
+
export function headings(text) {
|
|
38
|
+
const seen = new Map();
|
|
39
|
+
return scan(text).flatMap(({ text: line, line: number, fenced }) => {
|
|
40
|
+
const match = fenced ? null : ATX.exec(line);
|
|
41
|
+
if (match === null) {
|
|
42
|
+
return [];
|
|
43
|
+
}
|
|
44
|
+
const [, hashes = "", content = ""] = match;
|
|
45
|
+
const base = slug(content);
|
|
46
|
+
const count = seen.get(base) ?? 0;
|
|
47
|
+
seen.set(base, count + 1);
|
|
48
|
+
return [{ level: hashes.length, text: content, anchor: count === 0 ? base : `${base}-${count}`, line: number }];
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
function cells(line) {
|
|
52
|
+
return line
|
|
53
|
+
.trim()
|
|
54
|
+
.replace(/^\|/, "")
|
|
55
|
+
.replace(/\|$/, "")
|
|
56
|
+
.split("|")
|
|
57
|
+
.map((cell) => cell.trim());
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* The body rows of the tables in the section under the heading whose text is `title`, up to the next
|
|
61
|
+
* heading of its level or above; empty when there is no such heading.
|
|
62
|
+
*/
|
|
63
|
+
export function tableRows(text, title) {
|
|
64
|
+
const all = headings(text);
|
|
65
|
+
const start = all.find((heading) => heading.text.toLowerCase() === title.toLowerCase());
|
|
66
|
+
if (start === undefined) {
|
|
67
|
+
return [];
|
|
68
|
+
}
|
|
69
|
+
const end = all.find((heading) => heading.line > start.line && heading.level <= start.level)?.line ?? Infinity;
|
|
70
|
+
const rows = scan(text)
|
|
71
|
+
.filter(({ line, fenced }) => line > start.line && line < end && !fenced)
|
|
72
|
+
.filter(({ text: line }) => line.trim().startsWith("|"))
|
|
73
|
+
.map(({ text: line, line: number }) => ({ cells: cells(line), line: number }));
|
|
74
|
+
// A table's header row is the one above its delimiter row, which holds only dashes, colons and spaces.
|
|
75
|
+
const isDelimiter = (row) => row !== undefined && row.cells.every((cell) => /^:?-+:?$/.test(cell));
|
|
76
|
+
return rows.filter((row, index) => !isDelimiter(row) && !isDelimiter(rows[index + 1]));
|
|
77
|
+
}
|
|
78
|
+
/** Every backticked span in `text`, without its backticks. */
|
|
79
|
+
export function codeSpans(text) {
|
|
80
|
+
return Array.from(text.matchAll(/`([^`]+)`/g), ([, span = ""]) => span);
|
|
81
|
+
}
|
|
82
|
+
/** A code span: a run of backticks, then the text up to the next run of as many. */
|
|
83
|
+
const CODE_SPAN = /(?<!`)(`+)(?!`).*?(?<!`)\1(?!`)/g;
|
|
84
|
+
/** `text` with each fenced line and each code span blanked to spaces, so every offset and line is kept. */
|
|
85
|
+
export function prose(text) {
|
|
86
|
+
return scan(text)
|
|
87
|
+
.map(({ text: line, fenced }) => fenced ? " ".repeat(line.length) : line.replace(CODE_SPAN, (span) => " ".repeat(span.length)))
|
|
88
|
+
.join("\n");
|
|
89
|
+
}
|
|
90
|
+
/** The target of an inline link or image, `<…>` or up to a space or `)`, and of a link definition, `[label]: target`. */
|
|
91
|
+
const INLINE_TARGET = /\]\(\s*(<[^>]*>|[^\s)]+)/g;
|
|
92
|
+
const DEFINITION = /^ {0,3}\[(?!\^)[^\]]+\]:[ \t]*(<[^>]*>|\S+)/;
|
|
93
|
+
/** Every link target in the prose of `text`, its angle brackets dropped, with its 1-based line. */
|
|
94
|
+
export function links(text) {
|
|
95
|
+
return prose(text)
|
|
96
|
+
.split("\n")
|
|
97
|
+
.flatMap((line, index) => {
|
|
98
|
+
const definition = DEFINITION.exec(line)?.[1];
|
|
99
|
+
const targets = [
|
|
100
|
+
...Array.from(line.matchAll(INLINE_TARGET), ([, target = ""]) => target),
|
|
101
|
+
...(definition === undefined ? [] : [definition])
|
|
102
|
+
];
|
|
103
|
+
return targets.map((target) => ({ target: target.replace(/^<(.*)>$/, "$1"), line: index + 1 }));
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
/** The anchors of the headings of `path` under `root`; none when it is not a file. */
|
|
107
|
+
export function anchorsOf(root, path, cache) {
|
|
108
|
+
let anchors = cache.get(path);
|
|
109
|
+
if (anchors === undefined) {
|
|
110
|
+
const full = join(root, path);
|
|
111
|
+
anchors = new Set(statSync(full).isFile() ? headings(readFileSync(full, "utf8")).map(({ anchor }) => anchor) : []);
|
|
112
|
+
cache.set(path, anchors);
|
|
113
|
+
}
|
|
114
|
+
return anchors;
|
|
115
|
+
}
|
package/dist/pins.d.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/** Versions by package name: what package.json pins, or what a catalog lists. */
|
|
2
|
+
export type Pins = Record<string, string>;
|
|
3
|
+
/**
|
|
4
|
+
* The default catalog in pnpm-workspace.yaml's text: each `name: version` line of its top-level
|
|
5
|
+
* `catalog:` block, a comment after the key allowed. Without the block, it is empty.
|
|
6
|
+
*/
|
|
7
|
+
export declare function parseCatalog(yaml: string): Pins;
|
|
8
|
+
/**
|
|
9
|
+
* The pins in a package.json's text, one naming the default catalog replaced by what `catalog` lists
|
|
10
|
+
* for it; a missing or malformed section pins nothing.
|
|
11
|
+
*/
|
|
12
|
+
export declare function parsePins(json: string, catalog: Pins): Pins;
|
|
13
|
+
/** The pins of the repository at `root`; throws when its package.json is not JSON. */
|
|
14
|
+
export declare function readPins(root: string): Pins;
|
|
15
|
+
/** `ref` with each `{name}` replaced by the exact version `pins` holds for package `name`. */
|
|
16
|
+
export declare function resolveRef(ref: string, pins: () => Pins): string;
|
package/dist/pins.js
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// The exact version a repository pins for a package, for a ref that names one: the root package.json's
|
|
2
|
+
// dependencies and devDependencies, a catalog pin read through pnpm-workspace.yaml's default catalog. A
|
|
3
|
+
// ref without `{name}` reads neither file, so a broken manifest fails only the References that name one.
|
|
4
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
const EXACT_VERSION = /^\d+\.\d+\.\d+(?:-[\w.]+)?$/;
|
|
7
|
+
/** The two ways a pin names the default catalog: by its name, or by pnpm's shorthand. */
|
|
8
|
+
const DEFAULT_CATALOG = new Set(["catalog:", "catalog:default"]);
|
|
9
|
+
/**
|
|
10
|
+
* The default catalog in pnpm-workspace.yaml's text: each `name: version` line of its top-level
|
|
11
|
+
* `catalog:` block, a comment after the key allowed. Without the block, it is empty.
|
|
12
|
+
*/
|
|
13
|
+
export function parseCatalog(yaml) {
|
|
14
|
+
const lines = yaml.split(/\r?\n/);
|
|
15
|
+
const start = lines.findIndex((line) => /^catalog:\s*(?:#.*)?$/.test(line));
|
|
16
|
+
const catalog = {};
|
|
17
|
+
if (start === -1) {
|
|
18
|
+
return catalog;
|
|
19
|
+
}
|
|
20
|
+
for (const line of lines.slice(start + 1)) {
|
|
21
|
+
if (line.trim() === "" || line.trim().startsWith("#")) {
|
|
22
|
+
continue;
|
|
23
|
+
}
|
|
24
|
+
const entry = /^\s+(["']?)([^"'\s:]+)\1:\s*(["']?)([^"'\s#]+)\3\s*(?:#.*)?$/.exec(line);
|
|
25
|
+
if (entry === null) {
|
|
26
|
+
break;
|
|
27
|
+
}
|
|
28
|
+
catalog[entry[2] ?? ""] = entry[4] ?? "";
|
|
29
|
+
}
|
|
30
|
+
return catalog;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* The pins in a package.json's text, one naming the default catalog replaced by what `catalog` lists
|
|
34
|
+
* for it; a missing or malformed section pins nothing.
|
|
35
|
+
*/
|
|
36
|
+
export function parsePins(json, catalog) {
|
|
37
|
+
const manifest = JSON.parse(json);
|
|
38
|
+
const pins = {};
|
|
39
|
+
if (typeof manifest !== "object" || manifest === null) {
|
|
40
|
+
return pins;
|
|
41
|
+
}
|
|
42
|
+
for (const key of ["dependencies", "devDependencies"]) {
|
|
43
|
+
const section = Reflect.get(manifest, key);
|
|
44
|
+
if (typeof section !== "object" || section === null) {
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
for (const [name, version] of Object.entries(section)) {
|
|
48
|
+
if (typeof version === "string") {
|
|
49
|
+
pins[name] = DEFAULT_CATALOG.has(version) ? (catalog[name] ?? version) : version;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return pins;
|
|
54
|
+
}
|
|
55
|
+
/** The pins of the repository at `root`; throws when its package.json is not JSON. */
|
|
56
|
+
export function readPins(root) {
|
|
57
|
+
const manifest = join(root, "package.json");
|
|
58
|
+
if (!existsSync(manifest)) {
|
|
59
|
+
return {};
|
|
60
|
+
}
|
|
61
|
+
const workspace = join(root, "pnpm-workspace.yaml");
|
|
62
|
+
const catalog = existsSync(workspace) ? parseCatalog(readFileSync(workspace, "utf8")) : {};
|
|
63
|
+
try {
|
|
64
|
+
return parsePins(readFileSync(manifest, "utf8"), catalog);
|
|
65
|
+
}
|
|
66
|
+
catch (error) {
|
|
67
|
+
throw new Error(`package.json: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
/** `ref` with each `{name}` replaced by the exact version `pins` holds for package `name`. */
|
|
71
|
+
export function resolveRef(ref, pins) {
|
|
72
|
+
return ref.replaceAll(/\{([^{}]+)\}/g, (_, name) => {
|
|
73
|
+
const version = pins()[name];
|
|
74
|
+
if (version === undefined) {
|
|
75
|
+
throw new Error(`ref ${ref} names ${name}, which package.json does not depend on`);
|
|
76
|
+
}
|
|
77
|
+
if (!EXACT_VERSION.test(version)) {
|
|
78
|
+
throw new Error(`ref ${ref} needs an exact version of ${name}, and its pin is ${version}`);
|
|
79
|
+
}
|
|
80
|
+
return version;
|
|
81
|
+
});
|
|
82
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/** The committed config, the only file the key is read from. */
|
|
2
|
+
export declare const CONFIG = ".ai/agentic.config.json";
|
|
3
|
+
export type Reference = {
|
|
4
|
+
dir: string;
|
|
5
|
+
repository: string;
|
|
6
|
+
ref: string;
|
|
7
|
+
only: string[];
|
|
8
|
+
skip: string[];
|
|
9
|
+
what: string;
|
|
10
|
+
when: string;
|
|
11
|
+
};
|
|
12
|
+
export type KeyProblem = {
|
|
13
|
+
path: string;
|
|
14
|
+
message: string;
|
|
15
|
+
};
|
|
16
|
+
export declare function isObject(value: unknown): value is Record<string, unknown>;
|
|
17
|
+
/** The References `value` declares, or the first problem in it, named by its path under the key. */
|
|
18
|
+
export declare function parseReferences(value: unknown): {
|
|
19
|
+
references: Reference[];
|
|
20
|
+
} | {
|
|
21
|
+
problem: KeyProblem;
|
|
22
|
+
};
|
|
23
|
+
/**
|
|
24
|
+
* The References the committed config at `root` declares, `undefined` without the file or the key,
|
|
25
|
+
* or the problem that stops the read: the file is not a JSON object, or the key fails the parser.
|
|
26
|
+
*/
|
|
27
|
+
export declare function readReferences(root: string): {
|
|
28
|
+
references: Reference[] | undefined;
|
|
29
|
+
} | {
|
|
30
|
+
problem: string;
|
|
31
|
+
};
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// The strict parser of the `references` key, the authority at runtime, and the reader of the committed
|
|
2
|
+
// config that holds it. `config/references.schema.json` documents the same contract, and
|
|
3
|
+
// `test/references-key.test.ts` runs one case list through both. The first problem stops the parse.
|
|
4
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
/** The committed config, the only file the key is read from. */
|
|
7
|
+
export const CONFIG = ".ai/agentic.config.json";
|
|
8
|
+
const FIELDS = ["dir", "repository", "ref", "only", "skip", "what", "when"];
|
|
9
|
+
const REQUIRED = ["dir", "repository", "ref", "what", "when"];
|
|
10
|
+
const DIR_NAME = /^[A-Za-z0-9_][A-Za-z0-9_.-]*$/;
|
|
11
|
+
// No whitespace and no leading `-`, which git would read as an option.
|
|
12
|
+
const GIT_ARGUMENT = /^[^\s-]\S*$/;
|
|
13
|
+
const INSIDE_CLONE = /^(?!\/)(?!(?:.*\/)?\.\.(?:\/|$)).+$/;
|
|
14
|
+
class KeyError extends Error {
|
|
15
|
+
path;
|
|
16
|
+
constructor(path, message) {
|
|
17
|
+
super(message);
|
|
18
|
+
this.path = path;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
function kind(value) {
|
|
22
|
+
if (value === null) {
|
|
23
|
+
return "null";
|
|
24
|
+
}
|
|
25
|
+
if (Array.isArray(value)) {
|
|
26
|
+
return "a list";
|
|
27
|
+
}
|
|
28
|
+
return typeof value === "object" ? "an object" : `a ${typeof value}`;
|
|
29
|
+
}
|
|
30
|
+
export function isObject(value) {
|
|
31
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
32
|
+
}
|
|
33
|
+
function nonEmpty(value, path) {
|
|
34
|
+
if (typeof value !== "string") {
|
|
35
|
+
throw new KeyError(path, `must be a string, not ${kind(value)}`);
|
|
36
|
+
}
|
|
37
|
+
if (value === "") {
|
|
38
|
+
throw new KeyError(path, "must not be empty");
|
|
39
|
+
}
|
|
40
|
+
return value;
|
|
41
|
+
}
|
|
42
|
+
function matching(value, path, pattern, rule) {
|
|
43
|
+
const string = nonEmpty(value, path);
|
|
44
|
+
if (!pattern.test(string)) {
|
|
45
|
+
throw new KeyError(path, `${JSON.stringify(string)} must be ${rule}`);
|
|
46
|
+
}
|
|
47
|
+
return string;
|
|
48
|
+
}
|
|
49
|
+
/** A list of paths inside the clone, empty when absent. */
|
|
50
|
+
function paths(value, path) {
|
|
51
|
+
if (value === undefined) {
|
|
52
|
+
return [];
|
|
53
|
+
}
|
|
54
|
+
if (!Array.isArray(value)) {
|
|
55
|
+
throw new KeyError(path, `must be a list of paths, not ${kind(value)}`);
|
|
56
|
+
}
|
|
57
|
+
return value.map((item, index) => matching(item, `${path}[${index}]`, INSIDE_CLONE, "relative to the clone and stay inside it"));
|
|
58
|
+
}
|
|
59
|
+
/** `only`: absent, or at least one path, so a typo never checks out nothing. */
|
|
60
|
+
function onlyPaths(value, path) {
|
|
61
|
+
if (Array.isArray(value) && value.length === 0) {
|
|
62
|
+
throw new KeyError(path, "must name at least one path");
|
|
63
|
+
}
|
|
64
|
+
return paths(value, path);
|
|
65
|
+
}
|
|
66
|
+
/** Whether `path` lies below the directory `dir`, a trailing `/` on either aside. */
|
|
67
|
+
function below(path, dir) {
|
|
68
|
+
return path.replace(/\/+$/, "").startsWith(`${dir.replace(/\/+$/, "")}/`);
|
|
69
|
+
}
|
|
70
|
+
function parseEntry(value, path) {
|
|
71
|
+
if (!isObject(value)) {
|
|
72
|
+
throw new KeyError(path, `must be an object, not ${kind(value)}`);
|
|
73
|
+
}
|
|
74
|
+
const unknown = Object.keys(value).find((field) => !FIELDS.includes(field));
|
|
75
|
+
if (unknown !== undefined) {
|
|
76
|
+
throw new KeyError(path, `unknown field ${JSON.stringify(unknown)}`);
|
|
77
|
+
}
|
|
78
|
+
const missing = REQUIRED.find((field) => !Object.hasOwn(value, field));
|
|
79
|
+
if (missing !== undefined) {
|
|
80
|
+
throw new KeyError(path, `missing field ${JSON.stringify(missing)}`);
|
|
81
|
+
}
|
|
82
|
+
const reference = {
|
|
83
|
+
dir: matching(value.dir, `${path}.dir`, DIR_NAME, "one directory name: letters, digits, _, . and -, not starting with . or -"),
|
|
84
|
+
repository: matching(value.repository, `${path}.repository`, GIT_ARGUMENT, "a URL git can fetch, with no whitespace and no leading -"),
|
|
85
|
+
ref: matching(value.ref, `${path}.ref`, GIT_ARGUMENT, "a branch or a tag, with no whitespace and no leading -"),
|
|
86
|
+
only: onlyPaths(value.only, `${path}.only`),
|
|
87
|
+
skip: paths(value.skip, `${path}.skip`),
|
|
88
|
+
what: nonEmpty(value.what, `${path}.what`),
|
|
89
|
+
when: nonEmpty(value.when, `${path}.when`)
|
|
90
|
+
};
|
|
91
|
+
const { only, skip } = reference;
|
|
92
|
+
const outside = only.length === 0 ? -1 : skip.findIndex((skipped) => !only.some((kept) => below(skipped, kept)));
|
|
93
|
+
if (outside !== -1) {
|
|
94
|
+
throw new KeyError(`${path}.skip[${outside}]`, `${JSON.stringify(skip[outside])} must lie inside one of the only paths`);
|
|
95
|
+
}
|
|
96
|
+
return reference;
|
|
97
|
+
}
|
|
98
|
+
/** The References `value` declares, or the first problem in it, named by its path under the key. */
|
|
99
|
+
export function parseReferences(value) {
|
|
100
|
+
try {
|
|
101
|
+
if (!Array.isArray(value)) {
|
|
102
|
+
throw new KeyError("references", `must be a list of entries, not ${kind(value)}`);
|
|
103
|
+
}
|
|
104
|
+
const references = [];
|
|
105
|
+
for (const [index, item] of value.entries()) {
|
|
106
|
+
const reference = parseEntry(item, `references[${index}]`);
|
|
107
|
+
const holder = references.findIndex(({ dir }) => dir === reference.dir);
|
|
108
|
+
if (holder !== -1) {
|
|
109
|
+
throw new KeyError(`references[${index}].dir`, `${JSON.stringify(reference.dir)} is the dir of references[${holder}] too`);
|
|
110
|
+
}
|
|
111
|
+
references.push(reference);
|
|
112
|
+
}
|
|
113
|
+
return { references };
|
|
114
|
+
}
|
|
115
|
+
catch (error) {
|
|
116
|
+
if (error instanceof KeyError) {
|
|
117
|
+
return { problem: { path: error.path, message: error.message } };
|
|
118
|
+
}
|
|
119
|
+
throw error;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* The References the committed config at `root` declares, `undefined` without the file or the key,
|
|
124
|
+
* or the problem that stops the read: the file is not a JSON object, or the key fails the parser.
|
|
125
|
+
*/
|
|
126
|
+
export function readReferences(root) {
|
|
127
|
+
const path = join(root, CONFIG);
|
|
128
|
+
if (!existsSync(path)) {
|
|
129
|
+
return { references: undefined };
|
|
130
|
+
}
|
|
131
|
+
let config;
|
|
132
|
+
try {
|
|
133
|
+
config = JSON.parse(readFileSync(path, "utf8"));
|
|
134
|
+
}
|
|
135
|
+
catch (error) {
|
|
136
|
+
return { problem: `${CONFIG}: not JSON: ${error instanceof Error ? error.message : String(error)}` };
|
|
137
|
+
}
|
|
138
|
+
if (!isObject(config)) {
|
|
139
|
+
return { problem: `${CONFIG}: not a JSON object` };
|
|
140
|
+
}
|
|
141
|
+
if (!Object.hasOwn(config, "references")) {
|
|
142
|
+
return { references: undefined };
|
|
143
|
+
}
|
|
144
|
+
const parsed = parseReferences(config.references);
|
|
145
|
+
if ("problem" in parsed) {
|
|
146
|
+
return { problem: `${CONFIG}: ${parsed.problem.path}: ${parsed.problem.message}` };
|
|
147
|
+
}
|
|
148
|
+
return { references: parsed.references };
|
|
149
|
+
}
|
package/dist/report.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export type Failure = {
|
|
2
|
+
file?: string;
|
|
3
|
+
line?: number;
|
|
4
|
+
rule: string;
|
|
5
|
+
message: string;
|
|
6
|
+
remedy: string;
|
|
7
|
+
};
|
|
8
|
+
/** `file:line: rule: message — remedy`, without the location where the failure has none. */
|
|
9
|
+
export declare function failureLine({ file, line, rule, message, remedy }: Failure): string;
|
|
10
|
+
/** `::error file=…,line=…,title=<rule>::<message> — <remedy>`, an annotation on the file where there is one. */
|
|
11
|
+
export declare function annotation({ file, line, rule, message, remedy }: Failure): string;
|
|
12
|
+
/** The failure as JSON: every key present, null where it has no file or line. */
|
|
13
|
+
export declare function failureJson({ file, line, rule, message, remedy }: Failure): Record<string, unknown>;
|
package/dist/report.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// A failure of the Gate as every consumer reads it: one line naming the file and line where there is
|
|
2
|
+
// one, the rule, what is wrong and the remedy; the same as a GitHub Actions workflow annotation; the
|
|
3
|
+
// same as JSON.
|
|
4
|
+
/** `file:line: rule: message — remedy`, without the location where the failure has none. */
|
|
5
|
+
export function failureLine({ file, line, rule, message, remedy }) {
|
|
6
|
+
const where = file === undefined ? "" : `${file}${line === undefined ? "" : `:${line}`}: `;
|
|
7
|
+
return `${where}${rule}: ${message} — ${remedy}`;
|
|
8
|
+
}
|
|
9
|
+
// The escaping `@actions/core` applies to a workflow command: `%`, CR and LF in the message, and `:` and
|
|
10
|
+
// `,` too in a property.
|
|
11
|
+
function escapeData(text) {
|
|
12
|
+
return text.replaceAll("%", "%25").replaceAll("\r", "%0D").replaceAll("\n", "%0A");
|
|
13
|
+
}
|
|
14
|
+
function escapeProperty(text) {
|
|
15
|
+
return escapeData(text).replaceAll(":", "%3A").replaceAll(",", "%2C");
|
|
16
|
+
}
|
|
17
|
+
/** `::error file=…,line=…,title=<rule>::<message> — <remedy>`, an annotation on the file where there is one. */
|
|
18
|
+
export function annotation({ file, line, rule, message, remedy }) {
|
|
19
|
+
const properties = [
|
|
20
|
+
...(file === undefined ? [] : [`file=${escapeProperty(file)}`]),
|
|
21
|
+
...(line === undefined ? [] : [`line=${line}`]),
|
|
22
|
+
`title=${escapeProperty(rule)}`
|
|
23
|
+
];
|
|
24
|
+
return `::error ${properties.join(",")}::${escapeData(`${message} — ${remedy}`)}`;
|
|
25
|
+
}
|
|
26
|
+
/** The failure as JSON: every key present, null where it has no file or line. */
|
|
27
|
+
export function failureJson({ file, line, rule, message, remedy }) {
|
|
28
|
+
return { file: file ?? null, line: line ?? null, rule, message, remedy };
|
|
29
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { Reference } from "./references-key.ts";
|
|
2
|
+
/** The block the key gives: one row per Reference in the key's order, or one line when it has none. */
|
|
3
|
+
export declare function renderBlock(references: Reference[]): string;
|
|
4
|
+
/**
|
|
5
|
+
* The Router's text with `block` in place of the one between its markers: as it is when the two say
|
|
6
|
+
* the same rows, the block replaced when they differ, appended when the Router has no markers; or
|
|
7
|
+
* the problem with a Router that has one marker without the other.
|
|
8
|
+
*/
|
|
9
|
+
export declare function withBlock(router: string, block: string): {
|
|
10
|
+
text: string;
|
|
11
|
+
} | {
|
|
12
|
+
problem: string;
|
|
13
|
+
};
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// The Router's block: a table of each Reference's `dir` and `when`, written from the `references` key
|
|
2
|
+
// between two markers. The setup writes it and the Sync checks it; the rest of the Router is the
|
|
3
|
+
// author's. A block is in step when its rows say what the key says, not its bytes, which a formatter pads.
|
|
4
|
+
const START = "<!-- references:start — written from .ai/agentic.config.json by the setup; edit the key -->";
|
|
5
|
+
const END = "<!-- references:end -->";
|
|
6
|
+
const START_LINE = /^<!-- references:start\b.*-->\s*$/;
|
|
7
|
+
const END_LINE = /^<!-- references:end -->\s*$/;
|
|
8
|
+
const NONE = "No Reference is declared yet: `/sdlc-references-add <repository>` adds one.";
|
|
9
|
+
/** The block the key gives: one row per Reference in the key's order, or one line when it has none. */
|
|
10
|
+
export function renderBlock(references) {
|
|
11
|
+
const body = references.length === 0
|
|
12
|
+
? [NONE]
|
|
13
|
+
: [
|
|
14
|
+
"| dir | reach for it when |",
|
|
15
|
+
"| --- | --- |",
|
|
16
|
+
...references.map((reference) => `| \`${reference.dir}\` | ${reference.when.replaceAll("|", "\\|")} |`)
|
|
17
|
+
];
|
|
18
|
+
return `${[START, ...body, END].join("\n")}\n`;
|
|
19
|
+
}
|
|
20
|
+
/** A table's delimiter row, dashes of any count. */
|
|
21
|
+
const DELIMITER = /^\|(?:\s*-+\s*\|)+$/;
|
|
22
|
+
/** What `lines` say once a formatter's work is undone: no blank lines, every cell trimmed, a run of dashes three. */
|
|
23
|
+
function normalized(lines) {
|
|
24
|
+
return lines
|
|
25
|
+
.map((line) => line.trim())
|
|
26
|
+
.filter((line) => line !== "")
|
|
27
|
+
.map((line) => {
|
|
28
|
+
const cells = line.split("|").map((cell) => cell.trim());
|
|
29
|
+
return (DELIMITER.test(line) ? cells.map((cell) => cell.replaceAll(/-+/g, "---")) : cells).join("|");
|
|
30
|
+
})
|
|
31
|
+
.join("\n");
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* The Router's text with `block` in place of the one between its markers: as it is when the two say
|
|
35
|
+
* the same rows, the block replaced when they differ, appended when the Router has no markers; or
|
|
36
|
+
* the problem with a Router that has one marker without the other.
|
|
37
|
+
*/
|
|
38
|
+
export function withBlock(router, block) {
|
|
39
|
+
const lines = router.split("\n");
|
|
40
|
+
const start = lines.findIndex((line) => START_LINE.test(line));
|
|
41
|
+
const end = lines.findIndex((line) => END_LINE.test(line));
|
|
42
|
+
if (start === -1 && end === -1) {
|
|
43
|
+
const head = router === "" || router.endsWith("\n") ? router : `${router}\n`;
|
|
44
|
+
return { text: `${head}${head === "" ? "" : "\n"}${block}` };
|
|
45
|
+
}
|
|
46
|
+
if (start === -1 || end === -1) {
|
|
47
|
+
const lone = start === -1
|
|
48
|
+
? "references:end marker without a references:start"
|
|
49
|
+
: "references:start marker without a references:end";
|
|
50
|
+
return { problem: `has a ${lone}; fix the pair by hand first` };
|
|
51
|
+
}
|
|
52
|
+
if (end < start) {
|
|
53
|
+
return { problem: "has its references:end marker before references:start; fix the pair by hand first" };
|
|
54
|
+
}
|
|
55
|
+
const next = block.trimEnd().split("\n");
|
|
56
|
+
if (normalized(lines.slice(start + 1, end)) === normalized(next.slice(1, -1))) {
|
|
57
|
+
return { text: router };
|
|
58
|
+
}
|
|
59
|
+
return { text: [...lines.slice(0, start), ...next, ...lines.slice(end + 1)].join("\n") };
|
|
60
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { type EnvFiles } from "./agentic-config.ts";
|
|
2
|
+
/** The auto-mode exception that lets a lease push through the classifier's default force-push block. */
|
|
3
|
+
export declare const LEASE_RULE = "Force push with --force-with-lease or --force-if-includes to a branch that is not master, main or production: allowed. The guardrails plugin's hook refuses a bare --force, -f or +refspec and any force to a protected branch, so the lease form is the safe one and needs no prompt.";
|
|
4
|
+
/** The irreversible set for the user file; `git stash clear` also in the rtk form a permission rule sees once rtk rewrites it. */
|
|
5
|
+
export declare const GLOBAL_DENY: string[];
|
|
6
|
+
/**
|
|
7
|
+
* The rules the setup removes from a repository's deny list. The guard lets a forced delete of a
|
|
8
|
+
* landed branch through, and a deny rule would refuse it whatever the guard decides.
|
|
9
|
+
*/
|
|
10
|
+
export declare const RETIRED_DENY: string[];
|
|
11
|
+
/**
|
|
12
|
+
* The env files the guard refuses in this repository, relative to its root: the verdict
|
|
13
|
+
* `hooks/block-env-access.sh` gives under `envFiles`, one predicate per file git knows — tracked,
|
|
14
|
+
* untracked or ignored. The tracked, allowed ones stay readable.
|
|
15
|
+
*/
|
|
16
|
+
export declare function refusedEnvFiles(root: string, envFiles: EnvFiles): string[];
|
|
17
|
+
/**
|
|
18
|
+
* The permissions.deny a repository gets: the git and gh sets twice, then Read and Edit per refused
|
|
19
|
+
* env file. A rule written here stays until someone removes it, so while a config layer is invalid
|
|
20
|
+
* the env rules come from the defaults, not from the strictest set the guards run on meanwhile.
|
|
21
|
+
*/
|
|
22
|
+
export declare function repositoryDeny(root: string): string[];
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// The permission rules the setup writes: the lease exception and the irreversible set for the user
|
|
2
|
+
// file, and a repository's deny backstop for a machine without jq.
|
|
3
|
+
import { spawnSync } from "node:child_process";
|
|
4
|
+
import { DEFAULT_GUARDRAILS, matchesEnvGlob, resolveConfig } from "./agentic-config.js";
|
|
5
|
+
/** The auto-mode exception that lets a lease push through the classifier's default force-push block. */
|
|
6
|
+
export const LEASE_RULE = "Force push with --force-with-lease or --force-if-includes to a branch that is not master, main or production: allowed. The guardrails plugin's hook refuses a bare --force, -f or +refspec and any force to a protected branch, so the lease form is the safe one and needs no prompt.";
|
|
7
|
+
/** The irreversible set for the user file; `git stash clear` also in the rtk form a permission rule sees once rtk rewrites it. */
|
|
8
|
+
export const GLOBAL_DENY = [
|
|
9
|
+
"Bash(rm -rf /)",
|
|
10
|
+
"Bash(rm -rf /*)",
|
|
11
|
+
"Bash(rm -rf ~)",
|
|
12
|
+
"Bash(rm -rf ~/)",
|
|
13
|
+
"Bash(rm -rf $HOME)",
|
|
14
|
+
"Bash(rm -rf .)",
|
|
15
|
+
"Bash(rm -rf ..)",
|
|
16
|
+
"Bash(shred *)",
|
|
17
|
+
"Bash(find * -delete)",
|
|
18
|
+
"Bash(git stash clear)",
|
|
19
|
+
"Bash(rtk git stash clear)"
|
|
20
|
+
];
|
|
21
|
+
// Exact or end-anchored spellings only. A prefix rule such as `git push --force *` would also match
|
|
22
|
+
// `git push --force-with-lease`, the one form the plugin lets through; the hooks cover the rest.
|
|
23
|
+
const GIT_DENY = [
|
|
24
|
+
"git push --force",
|
|
25
|
+
"git push -f",
|
|
26
|
+
"git push * --force",
|
|
27
|
+
"git push * -f",
|
|
28
|
+
"git push --delete *",
|
|
29
|
+
"git push * --delete *",
|
|
30
|
+
"git reset --hard",
|
|
31
|
+
"git reset --hard *",
|
|
32
|
+
"git clean -fd",
|
|
33
|
+
"git clean -fd *",
|
|
34
|
+
"git clean -fdx",
|
|
35
|
+
"git clean -xdf",
|
|
36
|
+
"git clean -df",
|
|
37
|
+
"git clean -f -d",
|
|
38
|
+
"git clean --force",
|
|
39
|
+
"git checkout .",
|
|
40
|
+
"git checkout -- .",
|
|
41
|
+
"git restore .",
|
|
42
|
+
"git restore -- .",
|
|
43
|
+
"git branch -f *",
|
|
44
|
+
"git branch --force *",
|
|
45
|
+
"git stash clear",
|
|
46
|
+
"git stash drop",
|
|
47
|
+
"git stash drop *",
|
|
48
|
+
"git worktree remove --force *",
|
|
49
|
+
"git worktree remove -f *"
|
|
50
|
+
];
|
|
51
|
+
// The gh commands the guards refuse. rtk rewrites `gh` as it does `git`, so they take both forms too.
|
|
52
|
+
const GH_DENY = ["gh repo delete", "gh repo delete *"];
|
|
53
|
+
/**
|
|
54
|
+
* The rules the setup removes from a repository's deny list. The guard lets a forced delete of a
|
|
55
|
+
* landed branch through, and a deny rule would refuse it whatever the guard decides.
|
|
56
|
+
*/
|
|
57
|
+
export const RETIRED_DENY = ["Bash(git branch -D *)", "Bash(rtk git branch -D *)"];
|
|
58
|
+
const ENV_NAME = /^\.env(\..+)?$|^\.envrc$/;
|
|
59
|
+
function git(root, args) {
|
|
60
|
+
const result = spawnSync("git", ["-C", root, ...args], { encoding: "utf8" });
|
|
61
|
+
return result.status === 0 ? result.stdout.trim() : undefined;
|
|
62
|
+
}
|
|
63
|
+
function listedIn(globs, file) {
|
|
64
|
+
return globs.some((glob) => matchesEnvGlob(glob, file));
|
|
65
|
+
}
|
|
66
|
+
function isEnvName(file) {
|
|
67
|
+
return ENV_NAME.test(file.slice(file.lastIndexOf("/") + 1));
|
|
68
|
+
}
|
|
69
|
+
function gitFiles(root, ...flags) {
|
|
70
|
+
const listed = git(root, ["-c", "core.quotePath=false", "ls-files", ...flags, "--exclude-standard"]) ?? "";
|
|
71
|
+
return listed.split("\n").filter((file) => file !== "");
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* The env files the guard refuses in this repository, relative to its root: the verdict
|
|
75
|
+
* `hooks/block-env-access.sh` gives under `envFiles`, one predicate per file git knows — tracked,
|
|
76
|
+
* untracked or ignored. The tracked, allowed ones stay readable.
|
|
77
|
+
*/
|
|
78
|
+
export function refusedEnvFiles(root, envFiles) {
|
|
79
|
+
const tracked = new Set(gitFiles(root, "-c"));
|
|
80
|
+
const known = new Set([...tracked, ...gitFiles(root, "-o"), ...gitFiles(root, "-o", "-i")]);
|
|
81
|
+
const refused = (file) => listedIn(envFiles.deny, file) ||
|
|
82
|
+
(!listedIn(envFiles.allow, file) && (envFiles.policy === "deny" || !tracked.has(file)));
|
|
83
|
+
return [...known].filter(isEnvName).filter(refused).toSorted();
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* The permissions.deny a repository gets: the git and gh sets twice, then Read and Edit per refused
|
|
87
|
+
* env file. A rule written here stays until someone removes it, so while a config layer is invalid
|
|
88
|
+
* the env rules come from the defaults, not from the strictest set the guards run on meanwhile.
|
|
89
|
+
*/
|
|
90
|
+
export function repositoryDeny(root) {
|
|
91
|
+
const rules = [...GIT_DENY, ...GH_DENY].flatMap((command) => [`Bash(${command})`, `Bash(rtk ${command})`]);
|
|
92
|
+
const resolved = resolveConfig(root);
|
|
93
|
+
const envFiles = resolved.problems.length === 0 ? resolved.config.guardrails.envFiles : DEFAULT_GUARDRAILS.envFiles;
|
|
94
|
+
for (const file of refusedEnvFiles(root, envFiles)) {
|
|
95
|
+
rules.push(`Read(./${file})`, `Edit(./${file})`);
|
|
96
|
+
}
|
|
97
|
+
return rules;
|
|
98
|
+
}
|