@afokapu/atdd-bun 0.10.31 → 0.10.33
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -120
- package/conventions/_profiles/architecture.yaml +190 -0
- package/conventions/_profiles/coder.yaml +804 -0
- package/conventions/_profiles/design.yaml +219 -0
- package/conventions/_profiles/docs.yaml +136 -0
- package/conventions/_profiles/htmx.yaml +436 -0
- package/conventions/_profiles/interlocking.yaml +248 -0
- package/conventions/_profiles/metrics.yaml +80 -0
- package/conventions/_profiles/planner.yaml +789 -0
- package/conventions/_profiles/runtime.yaml +62 -0
- package/conventions/_profiles/security.yaml +70 -0
- package/conventions/_profiles/telemetry.yaml +196 -0
- package/conventions/_profiles/tester.yaml +734 -0
- package/conventions/_profiles/topology.yaml +192 -0
- package/conventions/_profiles/traceability.yaml +94 -0
- package/conventions/atdd-bun.profiles/atdd-bun.profiles.presets.convention.yaml +21 -3
- package/conventions/planner.docs/planner.docs.area-index-required.convention.yaml +2 -2
- package/conventions/planner.docs/planner.docs.artifact-path-shape.convention.yaml +1 -1
- package/conventions/planner.docs/planner.docs.asciidoc-only.convention.yaml +0 -1
- package/conventions/planner.docs/planner.docs.identity-required.convention.yaml +1 -1
- package/conventions/planner.docs/planner.docs.undeclared-change.convention.yaml +1 -2
- package/detectors/bun_interlocking_coverage/checks/interlocking_train_sequence.mjs +7 -5
- package/detectors/planner_docs_capability/README.md +1 -1
- package/integrity.json +36 -50
- package/package.json +1 -1
- package/relationships.yaml +0 -151
- package/src/agent.ts +15 -31
- package/src/cli.ts +7 -8
- package/src/docs-capability.ts +6 -24
- package/src/enforce.ts +8 -9
- package/src/hooks.ts +4 -8
- package/src/index.ts +1 -2
- package/src/integrity.ts +3 -11
- package/src/profile-registry.ts +74 -0
- package/src/setup.ts +3 -5
- package/templates/agents/AGENTS.block.md +4 -8
- package/templates/github/atdd-bun.yml +0 -5
- package/conventions/delivery/delivery.approved-sha-resolves.convention.yaml +0 -33
- package/conventions/delivery/delivery.board.convention.yaml +0 -60
- package/conventions/delivery/delivery.config-schema.convention.yaml +0 -49
- package/conventions/delivery/delivery.evidence-schema.convention.yaml +0 -34
- package/conventions/delivery/delivery.findings-resolved.convention.yaml +0 -42
- package/conventions/delivery/delivery.merge-gate.convention.yaml +0 -46
- package/conventions/delivery/delivery.model-allowed.convention.yaml +0 -29
- package/conventions/delivery/delivery.operating-model.convention.yaml +0 -97
- package/conventions/delivery/delivery.review.convention.yaml +0 -42
- package/conventions/delivery/delivery.reviewer-independent.convention.yaml +0 -34
- package/conventions/delivery/delivery.stages-complete.convention.yaml +0 -33
- package/detectors/delivery_evidence/atdd.implementation.yaml +0 -33
- package/detectors/delivery_evidence/detect.mjs +0 -9
- package/detectors/delivery_evidence/fixtures/clean/atdd-bun.yaml +0 -8
- package/detectors/delivery_evidence/fixtures/clean/docs/delivery/tranches/api/evidence.yaml +0 -54
- package/detectors/delivery_evidence/fixtures/dirty/atdd-bun.yaml +0 -3
- package/detectors/delivery_evidence/fixtures/dirty/docs/delivery/tranches/api/evidence.yaml +0 -31
- package/detectors/delivery_evidence/fixtures/dirty/docs/delivery/tranches/data/evidence.yaml +0 -8
- package/detectors/delivery_evidence/fixtures/dirty/docs/delivery/tranches/orphan/README.md +0 -1
- package/detectors/delivery_evidence/fixtures/dirty/docs/delivery/tranches/ui/evidence.yaml +0 -5
- package/detectors/planner_docs_capability/fixtures/dirty_missing_index/docs/delivery/release.adoc +0 -10
- package/detectors/planner_docs_capability/fixtures/dirty_missing_index/docs/purpose/index.adoc +0 -5
- package/planner-schemas/delivery-config.schema.json +0 -95
- package/planner-schemas/delivery-evidence.schema.json +0 -103
- package/src/board-ui.ts +0 -91
- package/src/board.ts +0 -222
- package/src/delivery.ts +0 -567
- package/templates/agents/atdd/SKILL.md +0 -20
- package/templates/agents/delivery/SKILL.md +0 -15
package/relationships.yaml
CHANGED
|
@@ -95,17 +95,6 @@ nodes:
|
|
|
95
95
|
- coder.htmx.verb-endpoint-is-routed
|
|
96
96
|
- coder.htmx.verb-endpoint-same-origin
|
|
97
97
|
- coder.htmx.verb-mutation-signals-progress
|
|
98
|
-
- delivery.approved-sha-resolves
|
|
99
|
-
- delivery.board
|
|
100
|
-
- delivery.config-schema
|
|
101
|
-
- delivery.evidence-schema
|
|
102
|
-
- delivery.findings-resolved
|
|
103
|
-
- delivery.merge-gate
|
|
104
|
-
- delivery.model-allowed
|
|
105
|
-
- delivery.operating-model
|
|
106
|
-
- delivery.review
|
|
107
|
-
- delivery.reviewer-independent
|
|
108
|
-
- delivery.stages-complete
|
|
109
98
|
- planner.acceptance.abstract-fields-required
|
|
110
99
|
- planner.acceptance.authoring-guidelines
|
|
111
100
|
- planner.acceptance.boundary-kind-vocabulary
|
|
@@ -5208,143 +5197,3 @@ edges:
|
|
|
5208
5197
|
target_ref: planner.telemetry.tracking-plan-schema
|
|
5209
5198
|
type: requires
|
|
5210
5199
|
origin: "@afokapu/atdd-bun"
|
|
5211
|
-
- confidence: 1
|
|
5212
|
-
constraint: mandatory
|
|
5213
|
-
control: internal
|
|
5214
|
-
foundation: finish_to_start
|
|
5215
|
-
reason: evidence is judged against the stages the policy configures
|
|
5216
|
-
source_ref: delivery.evidence-schema
|
|
5217
|
-
strength: important
|
|
5218
|
-
target_ref: delivery.config-schema
|
|
5219
|
-
type: requires
|
|
5220
|
-
origin: "@afokapu/atdd-bun"
|
|
5221
|
-
- confidence: 1
|
|
5222
|
-
constraint: mandatory
|
|
5223
|
-
control: internal
|
|
5224
|
-
foundation: finish_to_start
|
|
5225
|
-
reason: allowed models and their preference order come from the policy
|
|
5226
|
-
source_ref: delivery.model-allowed
|
|
5227
|
-
strength: important
|
|
5228
|
-
target_ref: delivery.config-schema
|
|
5229
|
-
type: requires
|
|
5230
|
-
origin: "@afokapu/atdd-bun"
|
|
5231
|
-
- confidence: 1
|
|
5232
|
-
constraint: mandatory
|
|
5233
|
-
control: internal
|
|
5234
|
-
foundation: finish_to_start
|
|
5235
|
-
reason: models are read from a well-formed review entry
|
|
5236
|
-
source_ref: delivery.model-allowed
|
|
5237
|
-
strength: important
|
|
5238
|
-
target_ref: delivery.evidence-schema
|
|
5239
|
-
type: requires
|
|
5240
|
-
origin: "@afokapu/atdd-bun"
|
|
5241
|
-
- confidence: 1
|
|
5242
|
-
constraint: mandatory
|
|
5243
|
-
control: internal
|
|
5244
|
-
foundation: finish_to_start
|
|
5245
|
-
reason: independence is judged over the author and reviewer runs the record names
|
|
5246
|
-
source_ref: delivery.reviewer-independent
|
|
5247
|
-
strength: important
|
|
5248
|
-
target_ref: delivery.evidence-schema
|
|
5249
|
-
type: requires
|
|
5250
|
-
origin: "@afokapu/atdd-bun"
|
|
5251
|
-
- confidence: 1
|
|
5252
|
-
constraint: mandatory
|
|
5253
|
-
control: internal
|
|
5254
|
-
foundation: finish_to_start
|
|
5255
|
-
reason: outcomes are read from well-formed findings
|
|
5256
|
-
source_ref: delivery.findings-resolved
|
|
5257
|
-
strength: important
|
|
5258
|
-
target_ref: delivery.evidence-schema
|
|
5259
|
-
type: requires
|
|
5260
|
-
origin: "@afokapu/atdd-bun"
|
|
5261
|
-
- confidence: 1
|
|
5262
|
-
constraint: mandatory
|
|
5263
|
-
control: internal
|
|
5264
|
-
foundation: finish_to_start
|
|
5265
|
-
reason: a stage is approved only once its earlier findings are settled
|
|
5266
|
-
source_ref: delivery.stages-complete
|
|
5267
|
-
strength: important
|
|
5268
|
-
target_ref: delivery.findings-resolved
|
|
5269
|
-
type: requires
|
|
5270
|
-
origin: "@afokapu/atdd-bun"
|
|
5271
|
-
- confidence: 1
|
|
5272
|
-
constraint: mandatory
|
|
5273
|
-
control: internal
|
|
5274
|
-
foundation: start_to_start
|
|
5275
|
-
reason: a reviewer works read-only in a fresh process, as the independence rule judges from the record
|
|
5276
|
-
source_ref: delivery.review
|
|
5277
|
-
strength: important
|
|
5278
|
-
target_ref: delivery.reviewer-independent
|
|
5279
|
-
type: requires
|
|
5280
|
-
origin: "@afokapu/atdd-bun"
|
|
5281
|
-
- confidence: 1
|
|
5282
|
-
constraint: mandatory
|
|
5283
|
-
control: internal
|
|
5284
|
-
foundation: start_to_start
|
|
5285
|
-
reason: a reviewer settles a rebuttal in one round, as the findings rule judges from the record
|
|
5286
|
-
source_ref: delivery.review
|
|
5287
|
-
strength: important
|
|
5288
|
-
target_ref: delivery.findings-resolved
|
|
5289
|
-
type: requires
|
|
5290
|
-
origin: "@afokapu/atdd-bun"
|
|
5291
|
-
- confidence: 1
|
|
5292
|
-
constraint: mandatory
|
|
5293
|
-
control: internal
|
|
5294
|
-
foundation: start_to_start
|
|
5295
|
-
reason: the driver runs every review as the review convention describes
|
|
5296
|
-
source_ref: delivery.operating-model
|
|
5297
|
-
strength: important
|
|
5298
|
-
target_ref: delivery.review
|
|
5299
|
-
type: requires
|
|
5300
|
-
origin: "@afokapu/atdd-bun"
|
|
5301
|
-
- confidence: 1
|
|
5302
|
-
constraint: mandatory
|
|
5303
|
-
control: internal
|
|
5304
|
-
foundation: finish_to_start
|
|
5305
|
-
reason: the operating model leaves the record the evidence schema judges
|
|
5306
|
-
source_ref: delivery.operating-model
|
|
5307
|
-
strength: important
|
|
5308
|
-
target_ref: delivery.evidence-schema
|
|
5309
|
-
type: requires
|
|
5310
|
-
origin: "@afokapu/atdd-bun"
|
|
5311
|
-
- confidence: 1
|
|
5312
|
-
constraint: mandatory
|
|
5313
|
-
control: internal
|
|
5314
|
-
foundation: start_to_start
|
|
5315
|
-
reason: the coordinator and drivers dispatch work and receive results through the board
|
|
5316
|
-
source_ref: delivery.operating-model
|
|
5317
|
-
strength: important
|
|
5318
|
-
target_ref: delivery.board
|
|
5319
|
-
type: requires
|
|
5320
|
-
origin: "@afokapu/atdd-bun"
|
|
5321
|
-
- confidence: 1
|
|
5322
|
-
constraint: mandatory
|
|
5323
|
-
control: internal
|
|
5324
|
-
foundation: finish_to_start
|
|
5325
|
-
reason: the approved SHA is the one the closing review approved
|
|
5326
|
-
source_ref: delivery.approved-sha-resolves
|
|
5327
|
-
strength: important
|
|
5328
|
-
target_ref: delivery.stages-complete
|
|
5329
|
-
type: requires
|
|
5330
|
-
origin: "@afokapu/atdd-bun"
|
|
5331
|
-
- confidence: 1
|
|
5332
|
-
constraint: mandatory
|
|
5333
|
-
control: internal
|
|
5334
|
-
foundation: finish_to_start
|
|
5335
|
-
reason: the gate compares the branch head with an approved SHA that exists
|
|
5336
|
-
source_ref: delivery.merge-gate
|
|
5337
|
-
strength: important
|
|
5338
|
-
target_ref: delivery.approved-sha-resolves
|
|
5339
|
-
type: requires
|
|
5340
|
-
origin: "@afokapu/atdd-bun"
|
|
5341
|
-
- confidence: 1
|
|
5342
|
-
constraint: mandatory
|
|
5343
|
-
control: internal
|
|
5344
|
-
foundation: start_to_start
|
|
5345
|
-
reason: the gate merges a tranche whose lifecycle closed its acceptances
|
|
5346
|
-
source_ref: delivery.merge-gate
|
|
5347
|
-
strength: important
|
|
5348
|
-
target_ref: traceability.plan.executable-acceptance-has-test
|
|
5349
|
-
type: requires
|
|
5350
|
-
origin: "@afokapu/atdd-bun"
|
package/src/agent.ts
CHANGED
|
@@ -1,55 +1,39 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { readFile, rm, writeFile } from "node:fs/promises";
|
|
2
2
|
import { existsSync } from "node:fs";
|
|
3
|
-
import {
|
|
4
|
-
import { deliveryAdopted } from "./delivery";
|
|
3
|
+
import { join, resolve } from "node:path";
|
|
5
4
|
|
|
6
5
|
const root = resolve(import.meta.dir, "..");
|
|
7
6
|
const version = (await Bun.file(join(root, "package.json")).json() as { version: string }).version;
|
|
8
7
|
const render = async (path: string) => (await readFile(join(root, "templates/agents", path), "utf8")).replace("{{VERSION}}", version);
|
|
9
|
-
|
|
10
|
-
const skillPaths = [".agents/skills/atdd/SKILL.md", ".claude/skills/atdd/SKILL.md"];
|
|
11
|
-
/** The delivery skill, installed only where the delivery profile is adopted: generated path → template. */
|
|
12
|
-
export const deliverySkillFiles = [".agents/skills", ".claude/skills"].map(base => [`${base}/delivery/SKILL.md`, "delivery/SKILL.md"] as const);
|
|
13
|
-
/** Generated files an earlier version installed and this one no longer ships: the review contract, now the skill's Reviewer section. */
|
|
14
|
-
const retiredFiles = [".agents/skills/delivery/review.md", ".claude/skills/delivery/review.md"];
|
|
15
|
-
/** Whether the repository's atdd-bun.yaml adopts the delivery profile. */
|
|
16
|
-
export async function deliveryInstalled(repo: string): Promise<boolean> {
|
|
17
|
-
const file = join(repo, "atdd-bun.yaml");
|
|
18
|
-
try { return existsSync(file) && deliveryAdopted(Bun.YAML.parse(await readFile(file, "utf8"))); } catch { return false; }
|
|
19
|
-
}
|
|
8
|
+
const legacySkillPaths = [".agents/skills/atdd/SKILL.md", ".claude/skills/atdd/SKILL.md"];
|
|
20
9
|
// AGENTS.md is read by Codex, Cursor and most agents; CLAUDE.md by Claude Code. Both carry the same block.
|
|
21
10
|
export const instructionPaths = ["AGENTS.md", "CLAUDE.md"];
|
|
22
11
|
const block = /<!-- atdd-bun:start[\s\S]*?<!-- atdd-bun:end -->\n?/;
|
|
12
|
+
const generatedSkill = /<!-- Generated by @afokapu\/atdd-bun /;
|
|
23
13
|
/** `current` with the managed block replaced, or appended after at least one blank line; `current` itself is kept. */
|
|
24
14
|
const withBlock = (current: string, managed: string) => block.test(current) ? current.replace(block, managed) : `${current}${current ? "\n\n".slice(current.match(/\n{0,2}$/)![0].length) : ""}${managed}`;
|
|
25
15
|
|
|
26
|
-
/** Write the
|
|
27
|
-
*
|
|
16
|
+
/** Write the managed ATDD block in AGENTS.md and CLAUDE.md. Existing files and an existing block are kept unless
|
|
17
|
+
* `replace`; the rest of each instruction file is never touched. Generated legacy skills are retired automatically. */
|
|
28
18
|
export async function agentInit(repo = process.cwd(), replace = false) {
|
|
29
|
-
const written: string[] = [], kept: string[] = [];
|
|
30
|
-
for (const path of
|
|
31
|
-
const output = join(repo, path);
|
|
32
|
-
if (existsSync(output) && !replace) { kept.push(output); continue; }
|
|
33
|
-
await mkdir(dirname(output), { recursive: true }); await writeFile(output, await render("atdd/SKILL.md")); written.push(output);
|
|
34
|
-
}
|
|
35
|
-
if (await deliveryInstalled(repo)) for (const [path, template] of deliverySkillFiles) {
|
|
19
|
+
const written: string[] = [], kept: string[] = [], retired: string[] = [];
|
|
20
|
+
for (const path of legacySkillPaths) {
|
|
36
21
|
const output = join(repo, path);
|
|
37
|
-
if (existsSync(output)
|
|
38
|
-
await
|
|
22
|
+
if (!existsSync(output) || (!replace && !generatedSkill.test(await readFile(output, "utf8")))) continue;
|
|
23
|
+
await rm(output); retired.push(output);
|
|
39
24
|
}
|
|
40
|
-
for (const path of retiredFiles) if (existsSync(join(repo, path))) { await rm(join(repo, path)); written.push(`${join(repo, path)} (removed)`); }
|
|
41
25
|
const managed = await render("AGENTS.block.md");
|
|
42
26
|
for (const path of instructionPaths) {
|
|
43
27
|
const file = join(repo, path), current = existsSync(file) ? await readFile(file, "utf8") : "";
|
|
44
28
|
if (block.test(current) && !replace) { kept.push(file); continue; }
|
|
45
29
|
await writeFile(file, withBlock(current, managed)); written.push(file);
|
|
46
30
|
}
|
|
47
|
-
if (!written.length) return { ok: false, message: `${kept.join(", ")} exist; use --replace` };
|
|
48
|
-
return { ok: true, message: written.join("\n") };
|
|
31
|
+
if (!written.length && !retired.length) return { ok: false, message: `${kept.join(", ")} exist; use --replace` };
|
|
32
|
+
return { ok: true, message: [...written, ...retired.map(path => `${path} removed`)].join("\n") };
|
|
49
33
|
}
|
|
50
34
|
export async function agentStatus(repo = process.cwd()) {
|
|
51
|
-
const
|
|
52
|
-
const missing = expected.map(path => join(repo, path)).filter(path => !existsSync(path));
|
|
35
|
+
const missing: string[] = [];
|
|
53
36
|
for (const path of instructionPaths) { const agents = join(repo, path); if (!existsSync(agents) || !block.test(await readFile(agents, "utf8"))) missing.push(`${agents} (atdd-bun block)`); }
|
|
54
|
-
|
|
37
|
+
for (const path of legacySkillPaths) { const skill = join(repo, path); if (existsSync(skill) && generatedSkill.test(await readFile(skill, "utf8"))) missing.push(`${skill} (retired generated skill)`); }
|
|
38
|
+
return { ok: !missing.length, message: missing.length ? `missing: ${missing.join(", ")}` : instructionPaths.map(path => join(repo, path)).join("\n") };
|
|
55
39
|
}
|
package/src/cli.ts
CHANGED
|
@@ -8,7 +8,7 @@ import { journeyDocs } from "./journey-docs";
|
|
|
8
8
|
import { adrRegister } from "./docs-capability";
|
|
9
9
|
import { releaseCheck } from "./release";
|
|
10
10
|
import { initializeRepository } from "./setup";
|
|
11
|
-
import {
|
|
11
|
+
import { profileRegistries } from "./profile-registry";
|
|
12
12
|
|
|
13
13
|
const args = process.argv.slice(2);
|
|
14
14
|
const usage = {
|
|
@@ -20,16 +20,11 @@ const usage = {
|
|
|
20
20
|
"atdd-bun worktree <start|finish|status>",
|
|
21
21
|
"atdd-bun ci <init|status> [--replace]",
|
|
22
22
|
"atdd-bun agent <init|status> [--replace]",
|
|
23
|
+
"atdd-bun profiles registry [--check]",
|
|
23
24
|
"atdd-bun integrity [init|status] [--replace]",
|
|
24
25
|
"atdd-bun docs journeys [--out <dir>] [--check] [--force]",
|
|
25
26
|
"atdd-bun docs adr-register [--check]",
|
|
26
27
|
"atdd-bun release check",
|
|
27
|
-
"atdd-bun chat topic <program> [<tranche> [<stage> <round>]]",
|
|
28
|
-
"atdd-bun chat post <topic> --to <agent,...> [--kind K] [--stage S] [--reply-to ID] [--repo R] [--branch B] [--worktree W] [--goal G] (body on stdin)",
|
|
29
|
-
"atdd-bun chat read <topic> [--mine] [--since ID]",
|
|
30
|
-
"atdd-bun chat wait <topic[,topic...]> [--since ID] [--skip KIND,...] [--timeout SECONDS]",
|
|
31
|
-
"atdd-bun chat follow <topic[,topic...]> [--since ID] [--skip KIND,...] [--timeout SECONDS]",
|
|
32
|
-
"atdd-bun chat (the board: every topic on the left, the conversation on the right)",
|
|
33
28
|
],
|
|
34
29
|
profiles: profileNames,
|
|
35
30
|
note: "Use a profile directly, for example: atdd-bun planner. --profile planner remains supported for compatibility.",
|
|
@@ -101,7 +96,11 @@ if (args[0] === "agent") {
|
|
|
101
96
|
const result = args[1] === "init" ? await agentInit(process.cwd(), args.includes("--replace")) : args[1] === "status" ? await agentStatus() : fail("agent requires init or status");
|
|
102
97
|
console[result.ok ? "log" : "error"](result.message); process.exit(result.ok ? 0 : 1);
|
|
103
98
|
}
|
|
104
|
-
if (args[0] === "
|
|
99
|
+
if (args[0] === "profiles") {
|
|
100
|
+
if (args[1] !== "registry") fail("profiles requires registry");
|
|
101
|
+
const result = await profileRegistries({ check: args.includes("--check") });
|
|
102
|
+
console[result.ok ? "log" : "error"](result.message); process.exit(result.ok ? 0 : 1);
|
|
103
|
+
}
|
|
105
104
|
if (args[0] === "release") {
|
|
106
105
|
if (args[1] !== "check") fail("release requires check");
|
|
107
106
|
const result = await releaseCheck(); console[result.ok ? "log" : "error"](result.message); process.exit(result.ok ? 0 : 1);
|
package/src/docs-capability.ts
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { existsSync } from "node:fs";
|
|
2
2
|
import { journeyDocs, journeyDocsApply } from "./journey-docs";
|
|
3
|
-
import { DEFAULT_ROOT, deliveryAdopted, deliveryPolicy } from "./delivery";
|
|
4
3
|
import { mkdtemp, readdir, readFile, rm } from "node:fs/promises";
|
|
5
4
|
import { tmpdir } from "node:os";
|
|
6
5
|
import { basename, isAbsolute, join, relative, resolve } from "node:path";
|
|
@@ -18,7 +17,7 @@ export type DocumentationRender = { findings: DocumentationViolation[]; couldNot
|
|
|
18
17
|
|
|
19
18
|
const DOCS = "docs";
|
|
20
19
|
const DIST = "docs/dist";
|
|
21
|
-
const AREAS = ["docs", "docs/purpose", "docs/architecture", "docs/architecture/decisions", "docs/
|
|
20
|
+
const AREAS = ["docs", "docs/purpose", "docs/architecture", "docs/architecture/decisions", "docs/archive"];
|
|
22
21
|
const VERBS = ["decides", "supersedes", "implements", "depends-on"];
|
|
23
22
|
const ADR_DIR = "docs/architecture/decisions";
|
|
24
23
|
const ADR_INDEX = `${ADR_DIR}/index.adoc`;
|
|
@@ -51,23 +50,6 @@ export function parseAttributes(text: string): { attrs: Record<string, string>;
|
|
|
51
50
|
return { attrs, lines };
|
|
52
51
|
}
|
|
53
52
|
|
|
54
|
-
/** The delivery profile's records folder, when it is adopted: tranche records and reports are YAML and data, judged by
|
|
55
|
-
* that profile, never authored documentation. The docs profile leaves the folder (by default docs/delivery/tranches) to
|
|
56
|
-
* it; the rest of docs/delivery, the program's reasoning, stays documentation. */
|
|
57
|
-
async function deliveryRecords(root: string): Promise<(path: string) => boolean> {
|
|
58
|
-
const file = join(root, "atdd-bun.yaml");
|
|
59
|
-
try {
|
|
60
|
-
const data = existsSync(file) ? Bun.YAML.parse(await readFile(file, "utf8")) as Record<string, unknown> | null : null;
|
|
61
|
-
if (!deliveryAdopted(data)) return () => false;
|
|
62
|
-
// Only the one folder the delivery profile may use under docs/; a root configured anywhere else in docs/ is a delivery
|
|
63
|
-
// finding and exempts nothing here.
|
|
64
|
-
if (deliveryPolicy(data!.delivery).root !== DEFAULT_ROOT) return () => false;
|
|
65
|
-
// Records and data only: AsciiDoc there stays documentation, judged by every docs rule (the delivery profile also
|
|
66
|
-
// reports it as a file that does not belong in the records folder).
|
|
67
|
-
return path => path.startsWith(`${DEFAULT_ROOT}/`) && !path.endsWith(".adoc");
|
|
68
|
-
} catch { return () => false; }
|
|
69
|
-
}
|
|
70
|
-
|
|
71
53
|
async function documents(root: string): Promise<Document[]> {
|
|
72
54
|
const paths = await walk(join(root, DOCS), ".adoc");
|
|
73
55
|
return Promise.all(paths.filter(path => !generated(relative(root, path).replaceAll("\\", "/"))).map(async path => {
|
|
@@ -105,8 +87,8 @@ function adrViolations(docs: Document[]): DocumentationViolation[] {
|
|
|
105
87
|
}
|
|
106
88
|
|
|
107
89
|
export async function scanDocumentation(root: string): Promise<DocumentationViolation[]> {
|
|
108
|
-
const absolute = resolve(root), docs = await documents(absolute), output: DocumentationViolation[] = []
|
|
109
|
-
for (const path of await walk(join(absolute, DOCS), ".md")) { const file = relative(absolute, path).replaceAll("\\", "/"); if (!generated(file)
|
|
90
|
+
const absolute = resolve(root), docs = await documents(absolute), output: DocumentationViolation[] = [];
|
|
91
|
+
for (const path of await walk(join(absolute, DOCS), ".md")) { const file = relative(absolute, path).replaceAll("\\", "/"); if (!generated(file)) output.push(violation("planner.docs.asciidoc-only", file, 1, `authored markdown beneath docs/: ${file}. AsciiDoc is the only authored format; convert it, and convert a historical document INTO docs/archive/ rather than into a current area.`, lineAt(await readFile(path, "utf8"), 1))); }
|
|
110
92
|
for (const doc of docs) { const missing = ["doc-id", "status"].filter(name => !doc.attrs[name]); if (missing.length) output.push(violation("planner.docs.identity-required", doc.path, 1, `document declares no ${missing.map(name => `:${name}:`).join(" and no ")}. Identity and currency are both required: an id with no status is a node whose currency is unknown, a status with no id is a claim nothing can reference.`, lineAt(doc.text, 1))); }
|
|
111
93
|
const byId = new Map<string, Document[]>(); for (const doc of docs) if (doc.attrs["doc-id"]) byId.set(doc.attrs["doc-id"], [...(byId.get(doc.attrs["doc-id"]) ?? []), doc]);
|
|
112
94
|
for (const [id, group] of byId) if (group.length > 1) for (const doc of group) { const line = doc.lines["doc-id"] ?? 1; output.push(violation("planner.docs.doc-id-unique", doc.path, line, `doc-id ${JSON.stringify(id)} is declared by ${group.length} documents: ${group.map(d => d.path).join(", ")}. Resolution needs exactly one target per id.`, lineAt(doc.text, line))); }
|
|
@@ -124,12 +106,12 @@ async function journeyViewViolations(root: string): Promise<DocumentationViolati
|
|
|
124
106
|
return result.stale.map(file => violation("planner.docs.journey-view-current", file, 1, `${file} does not match what plan/ generates, so the journey documentation no longer shows the plan. Regenerate it with \`atdd-bun docs journeys\` and commit the result; never edit it by hand.`));
|
|
125
107
|
}
|
|
126
108
|
|
|
127
|
-
export function declarationViolations(declaration: DocumentationDeclaration | null, changeSet?: string[]
|
|
109
|
+
export function declarationViolations(declaration: DocumentationDeclaration | null, changeSet?: string[]): DocumentationViolation[] {
|
|
128
110
|
if (!declaration) return [];
|
|
129
111
|
const artifacts = Array.isArray(declaration.artifacts) ? declaration.artifacts : []; const output: DocumentationViolation[] = [];
|
|
130
112
|
if (declaration.impact !== "change" && declaration.impact !== "none") output.push(violation("planner.docs.artifact-path-shape", "<declaration>", 1, `declaration carries impact=${JSON.stringify(declaration.impact)}; the two total forms are ["change", "none"]. A malformed declaration is reported, never treated as nothing-to-check.`));
|
|
131
113
|
for (const [index, artifact] of artifacts.entries()) { const path = artifact.path ?? "", problems: string[] = []; if (!path) problems.push("declares no path"); else { if (!path.startsWith("docs/")) problems.push(`path ${JSON.stringify(path)} is outside the canonical tree (must begin "docs/")`); if (!path.endsWith(".adoc")) problems.push(`path ${JSON.stringify(path)} is not AsciiDoc (must end ".adoc")`); if (artifact.action === "archive" && !path.startsWith("docs/archive/")) problems.push(`archive destination ${JSON.stringify(path)} is outside "docs/archive/" — archiving must preserve history, never promote it into a current area`); } if (problems.length) output.push(violation("planner.docs.artifact-path-shape", path || "<declaration>", 1, `declared artifact[${index}] (action: ${artifact.action || "unset"}): ${problems.join("; ")}`)); }
|
|
132
|
-
if (changeSet) { const covered = new Set(artifacts.flatMap(a => [a.path, a.from]).filter((p): p is string => Boolean(p))); for (const path of [...new Set(changeSet)].sort()) if (path.startsWith("docs/") && !generated(path) && !
|
|
114
|
+
if (changeSet) { const covered = new Set(artifacts.flatMap(a => [a.path, a.from]).filter((p): p is string => Boolean(p))); for (const path of [...new Set(changeSet)].sort()) if (path.startsWith("docs/") && !generated(path) && !covered.has(path)) output.push(violation("planner.docs.undeclared-change", path, 1, `the change set touches ${path} and no declared artifact covers it. Declare it at RATIFY; if the change was not planned, the declaration was wrong at RATIFY and re-ratifying is the honest correction.`)); }
|
|
133
115
|
return output;
|
|
134
116
|
}
|
|
135
117
|
|
|
@@ -170,7 +152,7 @@ export async function renderDocumentation(root: string, timeoutMs = 120_000): Pr
|
|
|
170
152
|
async function checkDocumentationInner(input: { root: string; declaration: DocumentationDeclaration | null; changeSet: string[] | null; render?: () => Promise<DocumentationRender> }): Promise<DocumentationCheck> {
|
|
171
153
|
if (input.declaration?.impact === "none") return { verdict: "NOT_APPLICABLE", findings: [], checked: [] };
|
|
172
154
|
const corpus = await scanDocumentation(input.root); const findings: DocumentationCheck["findings"] = [...corpus]; const checked = (await documents(resolve(input.root))).map(d => d.path); let definite = corpus.length > 0;
|
|
173
|
-
if (input.declaration) { const declarationFindings = declarationViolations(input.declaration, input.changeSet ?? undefined
|
|
155
|
+
if (input.declaration) { const declarationFindings = declarationViolations(input.declaration, input.changeSet ?? undefined); findings.push(...declarationFindings); definite ||= declarationFindings.length > 0; checked.push("<declaration>"); if (input.declaration.impact === "change") for (const artifact of input.declaration.artifacts ?? []) if (["create", "modify"].includes(artifact.action ?? "") && artifact.path && !existsSync(join(input.root, artifact.path))) { findings.push(violation("planner.docs.artifact-path-shape", artifact.path, 1, `declared artifact ${JSON.stringify(artifact.path)} (action: ${artifact.action}) is not in the tree, so it was never examined; a declared document that was never written has not discharged the obligation.`)); definite = true; } } else findings.push({ rule_id: null, where: "<declaration>", message: "core supplied no documentation declaration, so no declaration-dependent rule could be evaluated. This is COULD_NOT_CHECK and it BLOCKS." });
|
|
174
156
|
if (input.declaration && input.changeSet === null) findings.push({ rule_id: null, where: "<change_set>", message: "core supplied no change set, so whether this diff touches docs/ without declaring it could not be established. This is COULD_NOT_CHECK and it BLOCKS." });
|
|
175
157
|
const rendered = await (input.render ?? (() => renderDocumentation(input.root)))(); findings.push(...rendered.findings); definite ||= rendered.findings.length > 0; if (rendered.couldNotCheck) findings.push({ rule_id: "planner.docs.reference-integrity", file: "docs/", line: 1, col: 1, evidence: rendered.couldNotCheck, source_line: "" }); else checked.push("<render:asciidoctor>");
|
|
176
158
|
// Fail closed on any rule id this capability does not declare, however it was built (a renderer can return anything).
|
package/src/enforce.ts
CHANGED
|
@@ -4,7 +4,8 @@ import { tmpdir } from "node:os";
|
|
|
4
4
|
import { join, resolve } from "node:path";
|
|
5
5
|
import { topologyFor } from "./topology";
|
|
6
6
|
|
|
7
|
-
export type Profile = "traceability" | "topology" | "docs" | "planner" | "telemetry" | "
|
|
7
|
+
export type Profile = "traceability" | "topology" | "docs" | "planner" | "telemetry" | "coder" | "tester" | "security" | "architecture" | "metrics" | "runtime" | "interlocking" | "htmx" | "design" | "all";
|
|
8
|
+
export type ConcreteProfile = Exclude<Profile, "all">;
|
|
8
9
|
|
|
9
10
|
export type Violation = {
|
|
10
11
|
rule_id: string;
|
|
@@ -22,13 +23,12 @@ export type EnforcementConfig = {
|
|
|
22
23
|
profiles?: Profile[];
|
|
23
24
|
};
|
|
24
25
|
|
|
25
|
-
const
|
|
26
|
+
export const profileImplementations: Record<ConcreteProfile, string[]> = {
|
|
26
27
|
traceability: ["atdd_traceability_closure"],
|
|
27
28
|
topology: ["atdd_topology"],
|
|
28
29
|
docs: ["planner_docs_capability"],
|
|
29
30
|
planner: ["planner_plan_integrity", "planner_schema_validation", "planner_static_validators", "atdd_topology"],
|
|
30
31
|
telemetry: ["planner_telemetry_plan", "bun_telemetry_code", "bun_telemetry_test"],
|
|
31
|
-
delivery: ["delivery_evidence"],
|
|
32
32
|
coder: ["bun_green_traceability_detector", "bun_clean_architecture_detector", "bun_ts_metrics_detector", "bun_fullstack_detector", "bun_design_system_detector", "bun_responsive_detector", "atdd_topology"],
|
|
33
33
|
tester: ["bun_tester_discipline_detector", "htmx_e2e_detector", "atdd_topology"],
|
|
34
34
|
security: ["bun_security_hygiene_detector"],
|
|
@@ -40,9 +40,8 @@ const profiles: Record<Exclude<Profile, "all">, string[]> = {
|
|
|
40
40
|
design: ["bun_design_system_detector", "bun_responsive_detector"],
|
|
41
41
|
};
|
|
42
42
|
|
|
43
|
-
type ConcreteProfile = Exclude<Profile, "all">;
|
|
44
43
|
/** Every profile but `all`. */
|
|
45
|
-
export const concreteProfiles = Object.keys(
|
|
44
|
+
export const concreteProfiles = Object.keys(profileImplementations) as ConcreteProfile[];
|
|
46
45
|
/** Profile names accepted by the CLI and public integrations. */
|
|
47
46
|
export const profileNames = [...concreteProfiles, "all"] as Profile[];
|
|
48
47
|
|
|
@@ -69,10 +68,10 @@ export function implementationsFor(requested: Profile[] = ["all"]): string[] {
|
|
|
69
68
|
const selected = new Set<string>();
|
|
70
69
|
for (const profile of requested) {
|
|
71
70
|
if (profile === "all") {
|
|
72
|
-
for (const ids of Object.values(
|
|
71
|
+
for (const ids of Object.values(profileImplementations)) ids.forEach((id) => selected.add(id));
|
|
73
72
|
continue;
|
|
74
73
|
}
|
|
75
|
-
const ids =
|
|
74
|
+
const ids = profileImplementations[profile];
|
|
76
75
|
if (!ids) throw new Error(`unknown enforcement profile: ${profile}`);
|
|
77
76
|
ids.forEach((id) => selected.add(id));
|
|
78
77
|
}
|
|
@@ -80,10 +79,10 @@ export function implementationsFor(requested: Profile[] = ["all"]): string[] {
|
|
|
80
79
|
}
|
|
81
80
|
|
|
82
81
|
/** The rule ids a detector's manifest declares it emits (emits_rule_ids and api_emits_rule_ids). */
|
|
83
|
-
export async function declaredRuleIds(implementation: string): Promise<Set<string>> {
|
|
82
|
+
export async function declaredRuleIds(implementation: string, detectors = detectorRoot): Promise<Set<string>> {
|
|
84
83
|
const ids = new Set<string>();
|
|
85
84
|
let inList = false;
|
|
86
|
-
for (const line of (await readFile(join(
|
|
85
|
+
for (const line of (await readFile(join(detectors, implementation, "atdd.implementation.yaml"), "utf8")).split("\n")) {
|
|
87
86
|
if (line === "emits_rule_ids:" || line === "api_emits_rule_ids:") { inList = true; continue; }
|
|
88
87
|
if (/^[A-Za-z_][\w-]*:/.test(line)) { inList = false; continue; }
|
|
89
88
|
const match = inList && line.match(/^\s*-\s+([^#\s]+)/);
|
package/src/hooks.ts
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import { chmod, mkdir, readdir, readFile, realpath, rm, writeFile } from "node:fs/promises";
|
|
2
2
|
import { existsSync } from "node:fs";
|
|
3
|
-
import { dirname, join, relative, resolve } from "node:path";
|
|
3
|
+
import { basename, dirname, join, relative, resolve } from "node:path";
|
|
4
4
|
import { enabledProfiles, enforce } from "./enforce";
|
|
5
5
|
import { topologyFor } from "./topology";
|
|
6
6
|
import { JOURNEY_DOCS_DIR } from "./journey-docs";
|
|
7
7
|
|
|
8
8
|
export type WorktreePolicy = { enabled: boolean; root: string; primary_directory: string; primary_branch: string; require_linked_worktree: boolean };
|
|
9
9
|
export type HookPolicy = { max_staged_files: number; max_staged_changed_lines: number; max_commits_per_push: number; max_registry_removed_lines: number; registry_paths: string[]; protected_branches: string[]; require_plan_reference: boolean; require_traceability: boolean; worktrees: WorktreePolicy };
|
|
10
|
-
export const defaultWorktreePolicy: WorktreePolicy = { enabled: false, root: "../worktrees", primary_directory: "
|
|
10
|
+
export const defaultWorktreePolicy: WorktreePolicy = { enabled: false, root: "../worktrees/{repo}", primary_directory: ".", primary_branch: "main", require_linked_worktree: true };
|
|
11
11
|
export const defaultHookPolicy: HookPolicy = { max_staged_files: 20, max_staged_changed_lines: 350, max_commits_per_push: 10, max_registry_removed_lines: 350, registry_paths: ["plan/_*.yaml", "plan/_*.yml", "contracts/_*.yaml", "contracts/_*.yml"], protected_branches: ["main", "master"], require_plan_reference: true, require_traceability: true, worktrees: defaultWorktreePolicy };
|
|
12
12
|
export const hookEvents = ["pre-commit", "commit-msg", "pre-push", "pre-merge-commit", "post-commit", "post-merge"] as const;
|
|
13
13
|
export type HookEvent = typeof hookEvents[number];
|
|
@@ -33,20 +33,16 @@ async function validation(root: string, changed: string[], full: boolean, regist
|
|
|
33
33
|
const inside = (parent: string, child: string) => { const path = relative(parent, child); return path === "" || (path !== ".." && !path.startsWith("../") && !path.startsWith("..\\")); };
|
|
34
34
|
async function primaryRoot(root: string): Promise<string | null> { const result = await git(root, ["rev-parse", "--git-common-dir"]); if (result.code) return null; return dirname(resolve(root, result.out)); }
|
|
35
35
|
export type WorktreeLayout = { primary: string; worktreeRoot: string; current: string; branch: string };
|
|
36
|
-
async function layout(root: string, cfg: WorktreePolicy): Promise<WorktreeLayout | { error: string }> { const current = await realpath(resolve(root)), commonRoot = await primaryRoot(current); if (!commonRoot) return { error: "not inside a Git worktree" }; const primary = await realpath(commonRoot), expectedPrimary = join(dirname(primary), cfg.primary_directory); if (primary !== expectedPrimary) return { error: `primary checkout must be ${expectedPrimary}, not ${primary}` }; const branch = (await git(primary, ["symbolic-ref", "--quiet", "--short", "HEAD"])).out; if (branch !== cfg.primary_branch) return { error: `primary checkout ${primary} must be on ${cfg.primary_branch}, not ${branch || "a detached HEAD"}` }; return { primary, worktreeRoot: resolve(primary, cfg.root), current, branch: (await git(current, ["symbolic-ref", "--quiet", "--short", "HEAD"])).out }; }
|
|
36
|
+
async function layout(root: string, cfg: WorktreePolicy): Promise<WorktreeLayout | { error: string }> { const current = await realpath(resolve(root)), commonRoot = await primaryRoot(current); if (!commonRoot) return { error: "not inside a Git worktree" }; const primary = await realpath(commonRoot), expectedPrimary = cfg.primary_directory === "." ? primary : join(dirname(primary), cfg.primary_directory); if (primary !== expectedPrimary) return { error: `primary checkout must be ${expectedPrimary}, not ${primary}` }; const branch = (await git(primary, ["symbolic-ref", "--quiet", "--short", "HEAD"])).out; if (branch !== cfg.primary_branch) return { error: `primary checkout ${primary} must be on ${cfg.primary_branch}, not ${branch || "a detached HEAD"}` }; return { primary, worktreeRoot: resolve(primary, cfg.root.replaceAll("{repo}", basename(primary))), current, branch: (await git(current, ["symbolic-ref", "--quiet", "--short", "HEAD"])).out }; }
|
|
37
37
|
async function worktreeCommitPolicy(root: string, cfg: WorktreePolicy): Promise<string | null> { if (!cfg.enabled || !cfg.require_linked_worktree) return null; const state = await layout(root, cfg); if ("error" in state) return state.error; if (state.current === state.primary) return `commits must use a linked worktree beneath ${state.worktreeRoot}; run: atdd-bun worktree start <branch>`; if (!inside(state.worktreeRoot, state.current)) return `linked worktree ${state.current} is outside required root ${state.worktreeRoot}`; if (!state.branch) return "commits require a named branch, not a detached HEAD"; return null; }
|
|
38
38
|
|
|
39
|
-
/** Where the pushed branch left its base branch. A delivery record unchanged since then was judged when it merged, so
|
|
40
|
-
* pre-push does not judge it again, exactly as the merge gate does not; with no base found, every record is judged. */
|
|
41
|
-
async function pushBase(root: string, remote: string, cfg: HookPolicy): Promise<string | null> { for (const ref of [`${remote}/HEAD`, ...cfg.protected_branches.map(name => `${remote}/${name}`)]) { const found = await git(root, ["merge-base", "HEAD", ref]); if (!found.code && found.out) return found.out; } return null; }
|
|
42
|
-
|
|
43
39
|
export async function runHook(event: HookEvent, root = process.cwd(), args: string[] = [], stdin = "") {
|
|
44
40
|
const cfg = await policy(root), branch = (await git(root, ["symbolic-ref", "--quiet", "--short", "HEAD"])).out;
|
|
45
41
|
if (["pre-commit", "pre-merge-commit"].includes(event) && cfg.protected_branches.includes(branch)) return bad(`protected branch ${branch} is blocked`);
|
|
46
42
|
if (["pre-commit", "pre-merge-commit"].includes(event)) { const violation = await worktreeCommitPolicy(root, cfg.worktrees); if (violation) return bad(violation); }
|
|
47
43
|
if (event === "pre-commit") { const staged = await files(root, ["diff", "--cached", "--name-only"]), registries = staged.filter(path => isRegistry(cfg, path)), generated = staged.filter(isGeneratedView), counted = staged.length - registries.length - generated.length, lines = (await stagedLineStats(root)).filter(row => !isRegistry(cfg, row.path) && !isGeneratedView(row.path)).reduce((n, row) => n + row.added + row.removed, 0); if (counted > cfg.max_staged_files) return bad(`staged files ${counted} exceed ${cfg.max_staged_files}`); if (lines > cfg.max_staged_changed_lines) return bad(`staged changed lines ${lines} exceed ${cfg.max_staged_changed_lines}`); return cfg.require_traceability || registries.length || generated.length ? validation(root, staged, false, registries.length > 0) : { ok: true, message: "ok" }; }
|
|
48
44
|
if (event === "commit-msg") { const deleted = (await files(root, ["diff", "--cached", "--name-only", "--diff-filter=D"])).length, stats = await stagedStats(root), lines = stats.reduce((n, row) => n + row.removed, 0), registryRemoved = stats.filter(row => isRegistry(cfg, row.path)).reduce((n, row) => n + row.removed - row.added, 0), message = args[0] && existsSync(args[0]) ? await readFile(args[0], "utf8") : "", approved = message.includes("[mass-delete-approved]"); if ((deleted > 50 || lines > 10_000) && !approved) return bad("mass delete requires [mass-delete-approved]"); return registryRemoved > cfg.max_registry_removed_lines && !approved ? bad(`registry removal of ${registryRemoved} net lines exceeds ${cfg.max_registry_removed_lines}; requires [mass-delete-approved]`) : { ok: true, message: "ok" }; }
|
|
49
|
-
if (event === "pre-push") { for (const row of stdin.split("\n").filter(Boolean).map(row => row.split(/\s+/))) { const [,,remote, remoteSha] = row, target = remote?.replace("refs/heads/", ""); if (target && cfg.protected_branches.includes(target)) return bad(`protected branch ${target} is blocked`); const local = row[1]; if (local && !/^0+$/.test(local)) { const range = !remoteSha || /^0+$/.test(remoteSha) ? `${local}^..${local}` : `${remoteSha}..${local}`, count = Number((await git(root, ["rev-list", "--count", range])).out); if (count > cfg.max_commits_per_push) return bad(`commits per push ${count} exceed ${cfg.max_commits_per_push}`); } }
|
|
45
|
+
if (event === "pre-push") { for (const row of stdin.split("\n").filter(Boolean).map(row => row.split(/\s+/))) { const [,,remote, remoteSha] = row, target = remote?.replace("refs/heads/", ""); if (target && cfg.protected_branches.includes(target)) return bad(`protected branch ${target} is blocked`); const local = row[1]; if (local && !/^0+$/.test(local)) { const range = !remoteSha || /^0+$/.test(remoteSha) ? `${local}^..${local}` : `${remoteSha}..${local}`, count = Number((await git(root, ["rev-list", "--count", range])).out); if (count > cfg.max_commits_per_push) return bad(`commits per push ${count} exceed ${cfg.max_commits_per_push}`); } } return validation(root, await files(root, ["diff", "--name-only", "HEAD~1..HEAD"]), true); }
|
|
50
46
|
if (event === "post-commit") { const result = await validation(root, await files(root, ["show", "--pretty=format:", "--name-only", "HEAD"]), false); return { ok: true, message: result.ok ? result.message : `advisory: ${result.message}` }; }
|
|
51
47
|
return { ok: true, message: "ok" };
|
|
52
48
|
}
|
package/src/index.ts
CHANGED
|
@@ -7,8 +7,6 @@ export type { PlanArtifact, PlanFinding, PlanGraph, PlanKind } from "./planner-k
|
|
|
7
7
|
export { validateStaticPlannerConventions } from "./planner-validators";
|
|
8
8
|
export { validateTelemetryPlan, loadTelemetryFiles, telemetryDecisionOf, CONCRETE_URN } from "./telemetry-plan";
|
|
9
9
|
export type { TelemetryPlanItem, TelemetryFile } from "./telemetry-plan";
|
|
10
|
-
export { canonicalRoot, gateMode, deliveryAdopted, deliveryPolicy, loadEvidence, loosenedDelivery, STAGES, validateDelivery } from "./delivery";
|
|
11
|
-
export type { DeliveryOptions, GateMode, DeliveryPolicy, EvidenceFile, Independence, Stage, StagePolicy } from "./delivery";
|
|
12
10
|
export { PLANNER_SCHEMA_RULE_ID, validatePlannerSchemas } from "./planner-schema-validator";
|
|
13
11
|
export { defaultHookPolicy, hookEvents, hooksStatus, installHooks, runHook, uninstallHooks } from "./hooks";
|
|
14
12
|
export type { HookEvent, HookPolicy } from "./hooks";
|
|
@@ -19,5 +17,6 @@ export type { Gap, Journey, JourneyModel, JourneyPath } from "./journey-docs";
|
|
|
19
17
|
export { checkIntegrity, checkInstalledPackage, formatIntegrity, integrityInit, loosenedPolicy, writeManifest } from "./integrity";
|
|
20
18
|
export type { IntegrityFinding, IntegrityOptions } from "./integrity";
|
|
21
19
|
export { releaseCheck } from "./release";
|
|
20
|
+
export { profileRegistries, renderProfileRegistries, PROFILE_REGISTRY_DIR } from "./profile-registry";
|
|
22
21
|
export { defaultTopology, topologyFor } from "./topology";
|
|
23
22
|
export type { Topology } from "./topology";
|
package/src/integrity.ts
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
import { existsSync } from "node:fs";
|
|
2
2
|
import { mkdir, readdir, readFile, writeFile } from "node:fs/promises";
|
|
3
3
|
import { dirname, join, relative, resolve, sep } from "node:path";
|
|
4
|
-
import {
|
|
4
|
+
import { instructionPaths } from "./agent";
|
|
5
5
|
import { renderWorkflow } from "./ci";
|
|
6
|
-
import { loosenedDelivery } from "./delivery";
|
|
7
6
|
import { defaultHookPolicy, type HookPolicy } from "./hooks";
|
|
8
7
|
|
|
9
8
|
/**
|
|
@@ -19,7 +18,6 @@ const PACKAGE = "@afokapu/atdd-bun";
|
|
|
19
18
|
const ownRoot = resolve(import.meta.dir, "..");
|
|
20
19
|
export const MANIFEST = "integrity.json";
|
|
21
20
|
export const TEST_FILE = "atdd-bun.integrity.test.ts";
|
|
22
|
-
const SKILLS = [".agents/skills/atdd/SKILL.md", ".claude/skills/atdd/SKILL.md"];
|
|
23
21
|
const WORKFLOW = ".github/workflows/atdd-bun.yml";
|
|
24
22
|
const BLOCK = /<!-- atdd-bun:start[\s\S]*?<!-- atdd-bun:end -->/;
|
|
25
23
|
// Generated files carry the version that wrote them; an upgrade must not read as tampering.
|
|
@@ -100,10 +98,6 @@ async function checkGenerated(root: string, packageRoot: string, skipWorkflow =
|
|
|
100
98
|
if (skipWorkflow) { /* rendered from atdd-bun.yaml, which does not parse: reported by the caller */ }
|
|
101
99
|
else if (!existsSync(workflow)) findings.push({ file: WORKFLOW, detail: "is missing", restore: "bun run atdd-bun ci init --replace" });
|
|
102
100
|
else if (unstamp(await readFile(workflow, "utf8")) !== unstamp(await renderWorkflow(root))) findings.push({ file: WORKFLOW, detail: "was edited; it must match what the package generates (protected_branches decides its push branches)", restore: "bun run atdd-bun ci init --replace" });
|
|
103
|
-
for (const skill of SKILLS) await same(skill, "templates/agents/atdd/SKILL.md", "bun run atdd-bun agent init --replace");
|
|
104
|
-
// Required while delivery is adopted; protected whenever present, so turning delivery off and on cannot launder an edit.
|
|
105
|
-
const adopted = await deliveryInstalled(root);
|
|
106
|
-
for (const [path, template] of deliverySkillFiles) if (adopted || existsSync(join(root, path))) await same(path, `templates/agents/${template}`, "bun run atdd-bun agent init --replace");
|
|
107
101
|
await same(relative(root, await testFilePath(root)), "templates/agents/atdd-bun.integrity.test.ts", "bun run atdd-bun integrity init --replace");
|
|
108
102
|
const canonical = (await readFile(join(packageRoot, "templates/agents/AGENTS.block.md"), "utf8")).match(BLOCK)![0];
|
|
109
103
|
for (const file of instructionPaths) {
|
|
@@ -119,8 +113,7 @@ async function checkGenerated(root: string, packageRoot: string, skipWorkflow =
|
|
|
119
113
|
const explicitProfiles = (config: { profiles?: unknown }): string[] | null => Array.isArray(config.profiles) ? config.profiles.map(String) : null;
|
|
120
114
|
|
|
121
115
|
/** A policy with null-valued hook keys (and worktrees children) removed: YAML gives null for a key with no value, and the
|
|
122
|
-
* hooks read null as absent, so the comparison must too. Other keys keep their null
|
|
123
|
-
* readers that tell null apart from absent (`delivery:` with no value adopts delivery). */
|
|
116
|
+
* hooks read null as absent, so the comparison must too. Other keys, including profiles, keep their null. */
|
|
124
117
|
function withoutNulls(config: Record<string, unknown>): Record<string, unknown> {
|
|
125
118
|
const out = Object.fromEntries(Object.entries(config).filter(([key, value]) => value !== null || !(key in defaultHookPolicy)));
|
|
126
119
|
const worktrees = out.worktrees;
|
|
@@ -149,7 +142,7 @@ export function policyShapeErrors(config: Record<string, unknown>): string[] {
|
|
|
149
142
|
}
|
|
150
143
|
|
|
151
144
|
/** Names of the policy fields in `current` that are looser than in `base`. */
|
|
152
|
-
export function loosenedPolicy(base: Partial<HookPolicy> & { profiles?: unknown
|
|
145
|
+
export function loosenedPolicy(base: Partial<HookPolicy> & { profiles?: unknown }, current: Partial<HookPolicy> & { profiles?: unknown }): string[] {
|
|
153
146
|
const b = { ...defaultHookPolicy, ...base, worktrees: { ...defaultHookPolicy.worktrees, ...base.worktrees } }, c = { ...defaultHookPolicy, ...current, worktrees: { ...defaultHookPolicy.worktrees, ...current.worktrees } };
|
|
154
147
|
const out: string[] = [];
|
|
155
148
|
for (const key of ["max_staged_files", "max_staged_changed_lines", "max_commits_per_push", "max_registry_removed_lines"] as const) if (Number(c[key]) > Number(b[key])) out.push(`${key} ${b[key]} → ${c[key]}`);
|
|
@@ -165,7 +158,6 @@ export function loosenedPolicy(base: Partial<HookPolicy> & { profiles?: unknown;
|
|
|
165
158
|
if (before && !after) out.push(`profiles becomes implicit: the explicit list [${before.join(", ")}] was removed`);
|
|
166
159
|
const dropped = before && after ? before.filter(name => !after.includes(name)) : [];
|
|
167
160
|
if (dropped.length) out.push(`profiles drops ${dropped.join(", ")}`);
|
|
168
|
-
out.push(...loosenedDelivery(base, current));
|
|
169
161
|
return out;
|
|
170
162
|
}
|
|
171
163
|
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { join, relative, resolve } from "node:path";
|
|
4
|
+
import { concreteProfiles, declaredRuleIds, profileImplementations } from "./enforce";
|
|
5
|
+
|
|
6
|
+
export const PROFILE_REGISTRY_DIR = "conventions/_profiles";
|
|
7
|
+
type Convention = { rule_id: string; path: string };
|
|
8
|
+
type Relationship = Record<string, unknown> & { source_ref: string; target_ref: string; type: string };
|
|
9
|
+
type Graph = { edges: Relationship[] };
|
|
10
|
+
|
|
11
|
+
async function filesBelow(root: string, extension: string): Promise<string[]> {
|
|
12
|
+
const out: string[] = [];
|
|
13
|
+
for (const entry of await readdir(root, { withFileTypes: true })) {
|
|
14
|
+
const path = join(root, entry.name);
|
|
15
|
+
if (entry.isDirectory()) out.push(...await filesBelow(path, extension));
|
|
16
|
+
else if (entry.name.endsWith(extension)) out.push(path);
|
|
17
|
+
}
|
|
18
|
+
return out;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function conventionIndex(root: string): Promise<Map<string, Convention>> {
|
|
22
|
+
const index = new Map<string, Convention>();
|
|
23
|
+
for (const base of ["planner-nodes/nodes", "conventions"]) for (const file of await filesBelow(join(root, base), ".convention.yaml")) {
|
|
24
|
+
const rule_id = (Bun.YAML.parse(await readFile(file, "utf8")) as { rule_id?: unknown }).rule_id;
|
|
25
|
+
if (typeof rule_id !== "string") continue;
|
|
26
|
+
const path = relative(root, file).replaceAll("\\", "/");
|
|
27
|
+
if (index.has(rule_id)) throw new Error(`multiple convention files declare ${rule_id}: ${index.get(rule_id)!.path}, ${path}`);
|
|
28
|
+
index.set(rule_id, { rule_id, path });
|
|
29
|
+
}
|
|
30
|
+
return index;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const sorted = <T>(items: Iterable<T>, compare: (left: T, right: T) => number = (left, right) => String(left).localeCompare(String(right))) => [...items].sort(compare);
|
|
34
|
+
const relationshipKey = (edge: Relationship) => `${edge.source_ref}\u0000${edge.target_ref}\u0000${edge.type}`;
|
|
35
|
+
const scalar = (value: unknown) => value === null ? "null" : typeof value === "string" ? JSON.stringify(value) : typeof value === "number" || typeof value === "boolean" ? String(value) : (() => { throw new Error(`profile registry cannot render non-scalar value ${JSON.stringify(value)}`); })();
|
|
36
|
+
const fields = (value: Record<string, unknown>, indent: string) => sorted(Object.entries(value), ([left], [right]) => left.localeCompare(right)).map(([key, entry]) => `${indent}${key}: ${scalar(entry)}`);
|
|
37
|
+
const render = (profile: string, implementations: string[], conventions: Convention[], relationships: Relationship[]) => [
|
|
38
|
+
'schema_version: "1.0.0"', `profile: ${scalar(profile)}`,
|
|
39
|
+
"implementations:", ...implementations.map(implementation => ` - ${scalar(implementation)}`),
|
|
40
|
+
"conventions:", ...conventions.flatMap(convention => [` - rule_id: ${scalar(convention.rule_id)}`, ` path: ${scalar(convention.path)}`]),
|
|
41
|
+
"relationships:", ...relationships.flatMap(edge => { const entries = fields(edge, " "); return [` - ${entries[0]!.slice(4)}`, ...entries.slice(1)]; }), "",
|
|
42
|
+
].join("\n");
|
|
43
|
+
|
|
44
|
+
/** Deterministically project profiles, detector manifests, conventions, and direct graph edges into small registries. */
|
|
45
|
+
export async function renderProfileRegistries(root = resolve(import.meta.dir, "..")): Promise<Map<string, string>> {
|
|
46
|
+
const conventions = await conventionIndex(root);
|
|
47
|
+
const graph = Bun.YAML.parse(await readFile(join(root, "relationships.yaml"), "utf8")) as Graph;
|
|
48
|
+
if (!Array.isArray(graph.edges)) throw new Error("relationships.yaml has no edges list");
|
|
49
|
+
const rendered = new Map<string, string>();
|
|
50
|
+
for (const profile of concreteProfiles) {
|
|
51
|
+
const implementations = sorted(profileImplementations[profile]);
|
|
52
|
+
const declared = await Promise.all(implementations.map(implementation => declaredRuleIds(implementation, join(root, "detectors"))));
|
|
53
|
+
const ruleIds = sorted(new Set(declared.flatMap(ids => [...ids])));
|
|
54
|
+
const missing = ruleIds.filter(id => !conventions.has(id));
|
|
55
|
+
if (missing.length) throw new Error(`${profile} declares rule IDs without exactly one convention: ${missing.join(", ")}`);
|
|
56
|
+
const rules = new Set(ruleIds);
|
|
57
|
+
const relationships = sorted(graph.edges.filter(edge => rules.has(edge.source_ref) || rules.has(edge.target_ref)), (left, right) => relationshipKey(left).localeCompare(relationshipKey(right)));
|
|
58
|
+
rendered.set(`${PROFILE_REGISTRY_DIR}/${profile}.yaml`, render(profile, implementations, ruleIds.map(id => conventions.get(id)!), relationships));
|
|
59
|
+
}
|
|
60
|
+
return rendered;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Write or check the shipped profile registries. `check` reports every stale or unexpected generated file. */
|
|
64
|
+
export async function profileRegistries(options: { root?: string; check?: boolean } = {}) {
|
|
65
|
+
const root = resolve(options.root ?? resolve(import.meta.dir, "..")), expected = await renderProfileRegistries(root), directory = join(root, PROFILE_REGISTRY_DIR);
|
|
66
|
+
const actual = existsSync(directory) ? sorted((await readdir(directory)).filter(name => name.endsWith(".yaml")).map(name => `${PROFILE_REGISTRY_DIR}/${name}`)) : [];
|
|
67
|
+
const files: string[] = [];
|
|
68
|
+
for (const path of sorted(new Set([...expected.keys(), ...actual]))) if (!expected.has(path) || !existsSync(join(root, path)) || (await readFile(join(root, path), "utf8")) !== expected.get(path)) files.push(path);
|
|
69
|
+
if (options.check) return { ok: !files.length, message: files.length ? `profile registries have drifted: ${files.join(", ")}; run: atdd-bun profiles registry` : "profile registries are current", files };
|
|
70
|
+
await mkdir(directory, { recursive: true });
|
|
71
|
+
for (const path of actual.filter(path => !expected.has(path))) await rm(join(root, path));
|
|
72
|
+
for (const [path, content] of expected) await writeFile(join(root, path), content);
|
|
73
|
+
return { ok: true, message: `wrote ${expected.size} profile registries`, files: sorted(expected.keys()) };
|
|
74
|
+
}
|