kataster 0.4.0 → 0.6.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/.claude/THIRD_PARTY_NOTICES.md +4 -0
- package/.claude/agents/domain-architect.md +7 -3
- package/.claude/agents/researcher.md +77 -0
- package/.claude/hooks/validate-docs.ts +15 -0
- package/.claude/lib/architecture.ts +3 -33
- package/.claude/lib/backlog.ts +2 -0
- package/.claude/lib/config.ts +48 -0
- package/.claude/lib/discovery.ts +71 -1
- package/.claude/lib/engineering.ts +6 -0
- package/.claude/lib/gaps.ts +179 -0
- package/.claude/lib/glob.ts +39 -0
- package/.claude/lib/repo.ts +143 -19
- package/.claude/lib/research.ts +303 -0
- package/.claude/lib/slices.ts +529 -0
- package/.claude/settings.json +1 -0
- package/.claude/skills/adopt/SKILL.md +106 -31
- package/.claude/skills/adopt/scripts/gap-register.ts +61 -0
- package/.claude/skills/adopt/scripts/repo-map.ts +34 -2
- package/.claude/skills/adopt/scripts/slice-doc.ts +117 -0
- package/.claude/skills/adopt/scripts/slice.ts +163 -0
- package/.claude/skills/adopt/templates/context-map.as-is.md +22 -0
- package/.claude/skills/architect/SKILL.md +6 -1
- package/.claude/skills/domain-model/CONTEXT-FORMAT.md +1 -1
- package/.claude/skills/domain-model/SKILL.md +23 -3
- package/.claude/skills/event-storming/SKILL.md +3 -2
- package/.claude/skills/event-storming/schema/events.schema.json +2 -0
- package/.claude/skills/event-storming/templates/events.example.yaml +6 -0
- package/.claude/skills/feature/SKILL.md +8 -5
- package/.claude/skills/grilling/SKILL.md +15 -0
- package/.claude/skills/implement/SKILL.md +39 -3
- package/.claude/skills/implement/scripts/characterize.ts +252 -0
- package/.claude/skills/implement/scripts/story-context.ts +64 -0
- package/.claude/skills/implement/scripts/verify.ts +3 -0
- package/.claude/skills/improve-codebase-architecture/SKILL.md +4 -1
- package/.claude/skills/research/SKILL.md +149 -0
- package/.claude/skills/research/scripts/research-check.ts +105 -0
- package/.claude/skills/research/templates/research.md +29 -0
- package/.claude/skills/review-stories/SKILL.md +15 -5
- package/.claude/skills/shape/SKILL.md +24 -5
- package/.claude/skills/tdd/SKILL.md +10 -0
- package/.claude/skills/user-stories/SKILL.md +21 -2
- package/.claude/skills/user-stories/schema/story.schema.json +4 -0
- package/.claude/skills/user-stories/templates/story-template.md +1 -0
- package/.opencode/agents/domain-architect.md +7 -3
- package/.opencode/agents/researcher.md +91 -0
- package/.opencode/commands/adopt.md +1 -1
- package/.opencode/commands/feature.md +1 -1
- package/.opencode/commands/research.md +7 -0
- package/.opencode/commands/shape.md +1 -1
- package/AGENTS.md +14 -1
- package/CHANGELOG.md +49 -0
- package/README.md +78 -19
- package/guide/brownfield.md +41 -12
- package/guide/concepts.md +12 -1
- package/package.json +1 -1
- package/src/detect.mjs +9 -0
- package/src/install.mjs +4 -0
- package/templates/docs/domain/context-map.md +5 -1
- package/templates/docs/domain/events.yaml +3 -0
- package/templates/docs/research/README.md +70 -0
- package/templates/docs/stories/README.md +2 -0
- package/templates/docs/workflow.md +54 -13
package/.claude/lib/repo.ts
CHANGED
|
@@ -18,6 +18,7 @@ import { execFileSync } from "node:child_process";
|
|
|
18
18
|
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
|
|
19
19
|
import { basename, dirname, join, relative, resolve, sep } from "node:path";
|
|
20
20
|
import { graphLabel, graphOrigin, graphToolLabel, queryToolGraph, type ToolGraph } from "./toolgraph.ts";
|
|
21
|
+
import { classifyPackageRoles, clusterSlices, sourceFiles, type PackageRole, type SliceSuggestion } from "./slices.ts";
|
|
21
22
|
|
|
22
23
|
export const ROOT = resolve(import.meta.dirname, "..", "..");
|
|
23
24
|
export const REPO_MAP_JSON = join("docs", "architecture", "repo-map.json");
|
|
@@ -51,6 +52,9 @@ export interface RepoPackage {
|
|
|
51
52
|
hasTests: boolean;
|
|
52
53
|
adopted: boolean;
|
|
53
54
|
adoptedAt?: string;
|
|
55
|
+
/** Domain, layer or shared infrastructure – a layer package is not a domain slice. */
|
|
56
|
+
role?: PackageRole;
|
|
57
|
+
roleReasons?: string[];
|
|
54
58
|
/** Nx project name when it differs from the package name. */
|
|
55
59
|
projectName?: string;
|
|
56
60
|
}
|
|
@@ -66,6 +70,8 @@ export interface RepoMap {
|
|
|
66
70
|
tooling: Tooling;
|
|
67
71
|
packages: RepoPackage[];
|
|
68
72
|
ranking: RankedPackage[];
|
|
73
|
+
/** Concept clusters by shared stem/table – a suggestion for layered repositories, not a verdict. */
|
|
74
|
+
slices?: SliceSuggestion[];
|
|
69
75
|
/** Optional enrichment from the build tool; the manifest graph stays the source of truth. */
|
|
70
76
|
toolGraph?: ToolGraph;
|
|
71
77
|
}
|
|
@@ -73,6 +79,7 @@ export interface RepoMap {
|
|
|
73
79
|
export interface SliceProposal {
|
|
74
80
|
domain?: RepoPackage;
|
|
75
81
|
library?: RepoPackage;
|
|
82
|
+
cluster?: SliceSuggestion;
|
|
76
83
|
}
|
|
77
84
|
|
|
78
85
|
type Json = Record<string, unknown>;
|
|
@@ -612,8 +619,20 @@ export function scanRepo(root: string, options: { toolGraph?: boolean } = {}): R
|
|
|
612
619
|
for (const pkg of packages) pkg.consumers.sort();
|
|
613
620
|
packages.sort((a, b) => a.path.localeCompare(b.path));
|
|
614
621
|
|
|
622
|
+
// A layered repository needs concept slices: generic packages such as services/ or utils/ host many concepts
|
|
623
|
+
// and must not be proposed as a domain. The scan is shared by the role assignment and the clustering.
|
|
624
|
+
const files = sourceFiles(root);
|
|
625
|
+
const roles = classifyPackageRoles(root, packages, files);
|
|
626
|
+
for (const pkg of packages) {
|
|
627
|
+
const role = roles.get(pkg.path);
|
|
628
|
+
if (!role) continue;
|
|
629
|
+
pkg.role = role.role;
|
|
630
|
+
pkg.roleReasons = role.reasons;
|
|
631
|
+
}
|
|
632
|
+
|
|
615
633
|
const ranking = rankPackages(root, packages);
|
|
616
|
-
const
|
|
634
|
+
const slices = clusterSlices(root, packages, files);
|
|
635
|
+
const map: RepoMap = { version: 1, tooling, packages, ranking, slices };
|
|
617
636
|
// Enrichment only. The manifest graph remains the source of truth, so the map stays usable without the
|
|
618
637
|
// build tool: Turbo exposes a task graph, Nx a project graph, Bazel its BUILD deps.
|
|
619
638
|
if (options.toolGraph !== false && tooling.kind !== "none") {
|
|
@@ -654,7 +673,9 @@ export function rankPackages(root: string, packages: RepoPackage[]): RankedPacka
|
|
|
654
673
|
return packages
|
|
655
674
|
.map((pkg) => {
|
|
656
675
|
const changes = churn.get(pkg.name) ?? 0;
|
|
657
|
-
const
|
|
676
|
+
const base = 0.55 * (changes / maxChurn) + 0.3 * (pkg.consumers.length / maxConsumers) + 0.15 * (pkg.kind === "library" ? 0.4 : 1);
|
|
677
|
+
// A layer package can have churn and fan-in, but it is not a domain: keep it visible, rank it low.
|
|
678
|
+
const score = pkg.role === "layer" ? base * 0.15 : base;
|
|
658
679
|
const reasons: string[] = [];
|
|
659
680
|
if (changes) reasons.push(`${changes} changed file${changes === 1 ? "" : "s"} in recent history`);
|
|
660
681
|
if (pkg.consumers.length) {
|
|
@@ -662,6 +683,8 @@ export function rankPackages(root: string, packages: RepoPackage[]): RankedPacka
|
|
|
662
683
|
reasons.push(`${n} package${n === 1 ? "" : "s"} ${n === 1 ? "depends" : "depend"} on it`);
|
|
663
684
|
}
|
|
664
685
|
if (pkg.kind !== "package") reasons.push(pkg.kind === "library" ? "shared library" : pkg.kind);
|
|
686
|
+
if (pkg.role === "layer") reasons.push("layer package – hosts many concepts, not a domain slice");
|
|
687
|
+
if (pkg.role === "shared") reasons.push("shared infrastructure – not a domain slice");
|
|
665
688
|
if (!reasons.length) reasons.push("no churn or consumers recorded");
|
|
666
689
|
return { path: pkg.path, score: Math.round(score * 100) / 100, reasons };
|
|
667
690
|
})
|
|
@@ -671,15 +694,21 @@ export function rankPackages(root: string, packages: RepoPackage[]): RankedPacka
|
|
|
671
694
|
export function proposeSlices(map: RepoMap): SliceProposal {
|
|
672
695
|
const byPath = new Map(map.packages.map((p) => [p.path, p]));
|
|
673
696
|
const ranked = map.ranking.map((r) => byPath.get(r.path)).filter((p): p is RepoPackage => p !== undefined);
|
|
674
|
-
const
|
|
675
|
-
|
|
697
|
+
const domainCandidates = ranked.filter((p) => p.role !== "layer");
|
|
698
|
+
const domain =
|
|
699
|
+
domainCandidates.find((p) => p.kind === "app" || p.kind === "service") ??
|
|
700
|
+
domainCandidates.find((p) => p.kind !== "library") ??
|
|
701
|
+
domainCandidates[0];
|
|
702
|
+
|
|
703
|
+
const cluster = map.slices?.find((slice) => !slice.adopted);
|
|
704
|
+
if (!domain) return cluster ? { cluster } : {};
|
|
676
705
|
|
|
677
706
|
const libs = domain.internalDeps
|
|
678
707
|
.map((name) => map.packages.find((p) => p.name === name))
|
|
679
708
|
.filter((p): p is RepoPackage => p !== undefined && p.kind === "library")
|
|
680
709
|
.sort((a, b) => b.consumers.length - a.consumers.length);
|
|
681
710
|
const library = libs[0] ?? ranked.find((p) => p.kind === "library" && p.name !== domain.name);
|
|
682
|
-
return { domain, library };
|
|
711
|
+
return { domain, library, cluster };
|
|
683
712
|
}
|
|
684
713
|
|
|
685
714
|
// --------------------------------------------------------------------------
|
|
@@ -806,13 +835,13 @@ export function renderRepoMapMarkdown(map: RepoMap): string {
|
|
|
806
835
|
"",
|
|
807
836
|
"## Packages",
|
|
808
837
|
"",
|
|
809
|
-
"| Package | Path | Kind | Language | Depends on | Used by | Adopted |",
|
|
810
|
-
"
|
|
838
|
+
"| Package | Path | Kind | Role | Language | Depends on | Used by | Adopted |",
|
|
839
|
+
"|---|---|---|---|---|---|---|---|",
|
|
811
840
|
];
|
|
812
841
|
|
|
813
842
|
for (const pkg of [...map.packages].sort((a, b) => a.path.localeCompare(b.path))) {
|
|
814
843
|
out.push(
|
|
815
|
-
`| ${tableCell(pkg.name)} | \`${pkg.path}\` | ${pkg.kind} | ${pkg.language} | ` +
|
|
844
|
+
`| ${tableCell(pkg.name)} | \`${pkg.path}\` | ${pkg.kind} | ${pkg.role ?? "–"} | ${pkg.language} | ` +
|
|
816
845
|
`${pkg.internalDeps.length ? pkg.internalDeps.map((d) => `\`${d}\``).join(", ") : "–"} | ` +
|
|
817
846
|
`${pkg.consumers.length ? pkg.consumers.map((d) => `\`${d}\``).join(", ") : "–"} | ` +
|
|
818
847
|
`${pkg.adopted ? "yes" : "–"} |`,
|
|
@@ -820,6 +849,50 @@ export function renderRepoMapMarkdown(map: RepoMap): string {
|
|
|
820
849
|
}
|
|
821
850
|
out.push("");
|
|
822
851
|
|
|
852
|
+
const layers = map.packages.filter((pkg) => pkg.role === "layer");
|
|
853
|
+
if (layers.length) {
|
|
854
|
+
out.push(
|
|
855
|
+
"## Layer packages",
|
|
856
|
+
"",
|
|
857
|
+
"These packages are layers of the application, not domain slices. Their concepts cut across them.",
|
|
858
|
+
"",
|
|
859
|
+
"| Package | Path | Why |",
|
|
860
|
+
"|---|---|---|",
|
|
861
|
+
);
|
|
862
|
+
for (const pkg of layers) {
|
|
863
|
+
out.push(`| ${tableCell(pkg.name)} | \`${pkg.path}\` | ${tableCell((pkg.roleReasons ?? []).join("; "))} |`);
|
|
864
|
+
}
|
|
865
|
+
out.push("");
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
if (map.slices?.length) {
|
|
869
|
+
out.push(
|
|
870
|
+
"## Slice suggestions (shared concepts)",
|
|
871
|
+
"",
|
|
872
|
+
"Files that share a concept stem or a table across layers. These are suggestions – confirm the scope with the user.",
|
|
873
|
+
"",
|
|
874
|
+
"| Concept | Layers | Files | Tables | Why | Adopted |",
|
|
875
|
+
"|---|---|---|---|---|---|",
|
|
876
|
+
);
|
|
877
|
+
for (const slice of map.slices) {
|
|
878
|
+
out.push(
|
|
879
|
+
`| ${slice.name} | ${slice.layers.join(", ") || "–"} | ${slice.files.length} | ` +
|
|
880
|
+
`${slice.tables.map((t) => `\`${t}\``).join(", ") || "–"} | ${tableCell(slice.reasons.join("; "))} | ` +
|
|
881
|
+
`${slice.adopted ? "yes" : "–"} |`,
|
|
882
|
+
);
|
|
883
|
+
}
|
|
884
|
+
out.push("");
|
|
885
|
+
out.push(
|
|
886
|
+
"Adopt a concept slice with:",
|
|
887
|
+
"",
|
|
888
|
+
"```bash",
|
|
889
|
+
"node .claude/skills/adopt/scripts/slice.ts --cluster <name>",
|
|
890
|
+
"node .claude/skills/adopt/scripts/repo-map.ts --adopt-slice <name>",
|
|
891
|
+
"```",
|
|
892
|
+
"",
|
|
893
|
+
);
|
|
894
|
+
}
|
|
895
|
+
|
|
823
896
|
const edges = map.packages.flatMap((pkg) => pkg.internalDeps.map((dep) => [pkg, dep] as const));
|
|
824
897
|
if (edges.length) {
|
|
825
898
|
const nodes = new Map<string, RepoPackage>();
|
|
@@ -871,12 +944,23 @@ export function renderRepoMapMarkdown(map: RepoMap): string {
|
|
|
871
944
|
});
|
|
872
945
|
out.push("");
|
|
873
946
|
|
|
874
|
-
const { domain, library } = proposeSlices(map);
|
|
875
|
-
if (domain) {
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
947
|
+
const { domain, library, cluster } = proposeSlices(map);
|
|
948
|
+
if (domain || cluster) {
|
|
949
|
+
out.push("## Suggested first slice", "");
|
|
950
|
+
if (cluster) {
|
|
951
|
+
out.push(`- **Concept:** \`${cluster.name}\` – ${cluster.layers.join(", ") || "one layer"} (${cluster.files.length} file(s))`);
|
|
952
|
+
out.push(` Start with \`node .claude/skills/adopt/scripts/slice.ts --cluster ${cluster.name}\`.`);
|
|
953
|
+
}
|
|
954
|
+
if (domain) {
|
|
955
|
+
const label = domain.path === "." ? "root package" : `\`${domain.path}\``;
|
|
956
|
+
out.push(`- **Package:** ${label} (${domain.name})`);
|
|
957
|
+
if (library) out.push(`- **Shared library:** \`${library.path}\` (${library.name})`);
|
|
958
|
+
}
|
|
959
|
+
out.push("", "Adopt one concept or domain plus at most one shared library per session. Then mark it adopted:");
|
|
960
|
+
out.push("", "```bash");
|
|
961
|
+
if (cluster) out.push(`node .claude/skills/adopt/scripts/repo-map.ts --adopt-slice ${cluster.name}`);
|
|
962
|
+
if (domain) out.push(`node .claude/skills/adopt/scripts/repo-map.ts --adopt ${domain.path}`);
|
|
963
|
+
out.push("```", "");
|
|
880
964
|
}
|
|
881
965
|
return out.join("\n");
|
|
882
966
|
}
|
|
@@ -904,15 +988,26 @@ export function rootBlock(map: RepoMap, root = resolve(import.meta.dirname, ".."
|
|
|
904
988
|
lines.push(`- **Layout:** ${groupsList.slice(0, 8).map((g) => `\`${g.dir}/\` (${g.total})`).join(", ")}`);
|
|
905
989
|
}
|
|
906
990
|
lines.push("- **Detail:** `docs/architecture/repo-map.md` – package list, dependency edges, consumers, adoption ranking.");
|
|
907
|
-
|
|
991
|
+
const openSlices = (map.slices ?? []).filter((slice) => !slice.adopted).slice(0, 5);
|
|
992
|
+
if (openSlices.length) {
|
|
993
|
+
lines.push(
|
|
994
|
+
`- **Concept slices:** ${openSlices.map((slice) => `\`${slice.name}\` (${slice.layers.join(", ") || "one layer"})`).join(", ")}` +
|
|
995
|
+
" – candidates for `/adopt`, not confirmed domains.",
|
|
996
|
+
);
|
|
997
|
+
}
|
|
998
|
+
const adoptedSlices = (map.slices ?? []).filter((slice) => slice.adopted);
|
|
999
|
+
if (adopted.length || adoptedSlices.length) {
|
|
1000
|
+
const labels = [
|
|
1001
|
+
...adopted.map((p) => (p.path === "." ? "root package" : `\`${p.path}\``)),
|
|
1002
|
+
...adoptedSlices.map((slice) => `concept \`${slice.name}\``),
|
|
1003
|
+
];
|
|
908
1004
|
const rest = map.packages.length - adopted.length;
|
|
909
|
-
const labels = adopted.map((p) => (p.path === "." ? "root package" : `\`${p.path}\``));
|
|
910
1005
|
lines.push(
|
|
911
1006
|
`- **Adopted slices:** ${labels.join(", ")}` +
|
|
912
|
-
(rest ?
|
|
1007
|
+
(rest ? ". Everything else is **not adopted**: run `/adopt` before inferring domain there." : "."),
|
|
913
1008
|
);
|
|
914
1009
|
} else {
|
|
915
|
-
lines.push("- **No
|
|
1010
|
+
lines.push("- **No slice is adopted yet.** Run `/adopt` before inferring domain from code.");
|
|
916
1011
|
}
|
|
917
1012
|
lines.push("", "## Commands", "", "| Scope | Command |", "|---|---|");
|
|
918
1013
|
|
|
@@ -1173,12 +1268,41 @@ export function markAdopted(root: string, path: string, date = new Date().toISOS
|
|
|
1173
1268
|
return map;
|
|
1174
1269
|
}
|
|
1175
1270
|
|
|
1271
|
+
export function markSliceAdopted(root: string, name: string, date = new Date().toISOString().slice(0, 10)): RepoMap {
|
|
1272
|
+
const map = readRepoMap(root);
|
|
1273
|
+
if (!map) throw new Error(`no ${REPO_MAP_JSON} – run repo-map.ts first`);
|
|
1274
|
+
const slices = map.slices ?? (map.slices = []);
|
|
1275
|
+
let slice = slices.find((entry) => entry.name === name);
|
|
1276
|
+
if (!slice) {
|
|
1277
|
+
// An ad-hoc scope (glob, route, table, event) has no generated cluster; keep it as an explicit adoption.
|
|
1278
|
+
slice = {
|
|
1279
|
+
name,
|
|
1280
|
+
terms: [],
|
|
1281
|
+
tables: [],
|
|
1282
|
+
files: [],
|
|
1283
|
+
packages: [],
|
|
1284
|
+
layers: [],
|
|
1285
|
+
score: 0,
|
|
1286
|
+
reasons: ["marked adopted manually"],
|
|
1287
|
+
};
|
|
1288
|
+
slices.push(slice);
|
|
1289
|
+
}
|
|
1290
|
+
slice.adopted = true;
|
|
1291
|
+
slice.adoptedAt = date;
|
|
1292
|
+
writeRepoMap(root, map);
|
|
1293
|
+
writeRootBlock(root, map);
|
|
1294
|
+
return map;
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1176
1297
|
/** Stable view for staleness checks: generated docs and ranking may change with history. */
|
|
1177
1298
|
export function stableMap(map: RepoMap): string {
|
|
1178
1299
|
const packages = [...map.packages]
|
|
1179
1300
|
.sort((a, b) => a.path.localeCompare(b.path))
|
|
1180
1301
|
.map(({ adopted: _adopted, adoptedAt: _adoptedAt, consumers: _consumers, ...rest }) => rest);
|
|
1181
|
-
|
|
1302
|
+
const slices = [...(map.slices ?? [])]
|
|
1303
|
+
.sort((a, b) => a.name.localeCompare(b.name))
|
|
1304
|
+
.map(({ adopted: _adopted, adoptedAt: _adoptedAt, ...rest }) => rest);
|
|
1305
|
+
return JSON.stringify({ version: map.version, tooling: map.tooling, packages, slices }, null, 2);
|
|
1182
1306
|
}
|
|
1183
1307
|
|
|
1184
1308
|
export function findPackage(map: RepoMap, path: string): RepoPackage | undefined {
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Research files (`docs/research/<topic>.md`) – evidence for cross-cutting decisions, not a specification.
|
|
3
|
+
*
|
|
4
|
+
* The researcher agent is read-only and returns structured findings. The main session writes them verbatim into a
|
|
5
|
+
* research file with frontmatter: topic, question, created, retrieved, expires and the stack versions queried.
|
|
6
|
+
* Expiry is computed when the file is written or refreshed (`retrieved + research.expiryMonths`), never on read.
|
|
7
|
+
*
|
|
8
|
+
* This module owns the format, the review gate and the status helpers. It does not import discovery.ts: discovery.ts
|
|
9
|
+
* imports this module for story warnings, and a cycle would make both unreadable.
|
|
10
|
+
*
|
|
11
|
+
* Same rules as the rest of the payload: Node >= 22.18, erasable TypeScript only, one runtime dependency (`yaml`).
|
|
12
|
+
*/
|
|
13
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
14
|
+
import { basename, join, relative, resolve, sep } from "node:path";
|
|
15
|
+
import { parse as parseYaml } from "yaml";
|
|
16
|
+
import { loadResearchConfig, type Obj, type ResearchConfig } from "./config.ts";
|
|
17
|
+
|
|
18
|
+
export const ROOT = resolve(import.meta.dirname, "..", "..");
|
|
19
|
+
export const RESEARCH_DIR = join(ROOT, "docs", "research");
|
|
20
|
+
|
|
21
|
+
export type ResearchStatus = "valid" | "expired" | "missing" | "invalid";
|
|
22
|
+
|
|
23
|
+
/** Today's date in UTC, `YYYY-MM-DD`. */
|
|
24
|
+
export function today(): string {
|
|
25
|
+
return new Date().toISOString().slice(0, 10);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
|
29
|
+
const TOPIC_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
30
|
+
|
|
31
|
+
function isValidDate(value: unknown): value is string {
|
|
32
|
+
if (typeof value !== "string" || !DATE_RE.test(value)) return false;
|
|
33
|
+
const date = new Date(`${value}T00:00:00Z`);
|
|
34
|
+
return !Number.isNaN(date.getTime()) && date.toISOString().slice(0, 10) === value;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** `date` plus `months`, in UTC; a month-end date is clamped to the target month's last day. */
|
|
38
|
+
export function addMonths(date: string, months: number): string {
|
|
39
|
+
const base = new Date(`${date}T00:00:00Z`);
|
|
40
|
+
if (Number.isNaN(base.getTime())) throw new Error(`invalid date '${date}'`);
|
|
41
|
+
const target = new Date(Date.UTC(base.getUTCFullYear(), base.getUTCMonth() + Math.trunc(months), 1));
|
|
42
|
+
const lastDay = new Date(Date.UTC(target.getUTCFullYear(), target.getUTCMonth() + 1, 0)).getUTCDate();
|
|
43
|
+
const day = Math.min(base.getUTCDate(), lastDay);
|
|
44
|
+
return `${target.toISOString().slice(0, 7)}-${String(day).padStart(2, "0")}`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Project-relative POSIX path for display; absolute when the file is outside the project. */
|
|
48
|
+
export function relPath(path: string): string {
|
|
49
|
+
const r = relative(ROOT, path);
|
|
50
|
+
return r.startsWith("..") || resolve(r) === r ? path : r.split(sep).join("/");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** All research files: Markdown, no README, no `_`-prefixed drafts. */
|
|
54
|
+
export function researchFiles(): string[] {
|
|
55
|
+
if (!existsSync(RESEARCH_DIR)) return [];
|
|
56
|
+
return readdirSync(RESEARCH_DIR)
|
|
57
|
+
.filter((name) => name.endsWith(".md") && name !== "README.md" && !name.startsWith("_"))
|
|
58
|
+
.sort()
|
|
59
|
+
.map((name) => join(RESEARCH_DIR, name));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface ResearchDocument {
|
|
63
|
+
meta: Obj;
|
|
64
|
+
body: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Parses a research file. Throws on a missing or broken frontmatter. */
|
|
68
|
+
export function parseResearchFile(path: string): ResearchDocument {
|
|
69
|
+
const text = readFileSync(path, "utf8").replaceAll("\r\n", "\n");
|
|
70
|
+
if (!text.startsWith("---\n")) throw new Error("YAML frontmatter is missing (file must start with '---')");
|
|
71
|
+
const end = text.indexOf("\n---", 4);
|
|
72
|
+
if (end === -1) throw new Error("frontmatter is not closed with '---'");
|
|
73
|
+
const meta = parseYaml(text.slice(4, end)) ?? {};
|
|
74
|
+
if (meta === null || typeof meta !== "object" || Array.isArray(meta)) {
|
|
75
|
+
throw new Error("frontmatter must be an object");
|
|
76
|
+
}
|
|
77
|
+
return { meta: meta as Obj, body: text.slice(end + 4).replace(/^\n+/, "") };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface ResearchValidation {
|
|
81
|
+
errors: string[];
|
|
82
|
+
warnings: string[];
|
|
83
|
+
doc: ResearchDocument | null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const FINDING_FIELDS = ["Statement", "Source", "Retrieved", "Tier", "Confidence", "Domain-model impact"] as const;
|
|
87
|
+
|
|
88
|
+
/** Validates the persisted format. Expiry itself is a status, not a format error. */
|
|
89
|
+
export function validateResearchFile(path: string): ResearchValidation {
|
|
90
|
+
const errors: string[] = [];
|
|
91
|
+
const warnings: string[] = [];
|
|
92
|
+
let doc: ResearchDocument;
|
|
93
|
+
try {
|
|
94
|
+
doc = parseResearchFile(path);
|
|
95
|
+
} catch (error) {
|
|
96
|
+
return { errors: [error instanceof Error ? error.message : String(error)], warnings, doc: null };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const meta = doc.meta;
|
|
100
|
+
const topic = typeof meta.topic === "string" ? meta.topic : "";
|
|
101
|
+
if (!topic) {
|
|
102
|
+
errors.push("frontmatter: required field 'topic' is missing");
|
|
103
|
+
} else if (!TOPIC_RE.test(topic)) {
|
|
104
|
+
errors.push(`frontmatter: topic '${topic}' must be lowercase kebab-case`);
|
|
105
|
+
} else if (basename(path, ".md") !== topic) {
|
|
106
|
+
errors.push(`frontmatter: topic '${topic}' must match the file name '${basename(path)}'`);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (typeof meta.question !== "string" || !meta.question.trim()) {
|
|
110
|
+
errors.push("frontmatter: required field 'question' is missing or empty");
|
|
111
|
+
}
|
|
112
|
+
for (const field of ["created", "retrieved", "expires"] as const) {
|
|
113
|
+
if (!isValidDate(meta[field])) {
|
|
114
|
+
errors.push(`frontmatter: '${field}' must be a valid date (YYYY-MM-DD)`);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
if (!Array.isArray(meta.stack) || !meta.stack.every((entry) => typeof entry === "string")) {
|
|
118
|
+
errors.push("frontmatter: 'stack' must be a list of version strings");
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (isValidDate(meta.created) && isValidDate(meta.retrieved) && meta.created > meta.retrieved) {
|
|
122
|
+
errors.push("frontmatter: 'created' must not be after 'retrieved'");
|
|
123
|
+
}
|
|
124
|
+
if (isValidDate(meta.retrieved) && isValidDate(meta.expires) && meta.expires <= meta.retrieved) {
|
|
125
|
+
errors.push("frontmatter: 'expires' must be after 'retrieved'");
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (!/^#\s+Research:\s*\S/m.test(doc.body)) {
|
|
129
|
+
errors.push("body: heading '# Research: <topic>' is missing");
|
|
130
|
+
}
|
|
131
|
+
if (!/^##\s+Question\s*$/m.test(doc.body)) {
|
|
132
|
+
errors.push("body: section '## Question' is missing");
|
|
133
|
+
}
|
|
134
|
+
if (!/^##\s+Findings\s*$/m.test(doc.body)) {
|
|
135
|
+
errors.push("body: section '## Findings' is missing");
|
|
136
|
+
}
|
|
137
|
+
if (!/^##\s+Conflicts, caveats and gaps\s*$/m.test(doc.body)) {
|
|
138
|
+
warnings.push("body: section '## Conflicts, caveats and gaps' is missing – state gaps or say that there are none");
|
|
139
|
+
}
|
|
140
|
+
if (!/^##\s+Open questions for the grilling\s*$/m.test(doc.body)) {
|
|
141
|
+
warnings.push("body: section '## Open questions for the grilling' is missing");
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const headings = [...doc.body.matchAll(/^###\s+(F\d+)\s*[–-]\s*(.*)$/gm)];
|
|
145
|
+
if (!headings.length) {
|
|
146
|
+
errors.push("body: at least one finding ('### F1 – <short claim>') is required");
|
|
147
|
+
}
|
|
148
|
+
for (const heading of headings) {
|
|
149
|
+
const start = (heading.index ?? 0) + heading[0].length;
|
|
150
|
+
const rest = doc.body.slice(start);
|
|
151
|
+
const next = rest.search(/^###\s+/m);
|
|
152
|
+
const block = next === -1 ? rest : rest.slice(0, next);
|
|
153
|
+
for (const field of FINDING_FIELDS) {
|
|
154
|
+
const value = new RegExp(`^-\\s*\\*\\*${field}:\\*\\*\\s*(.+)$`, "m").exec(block)?.[1].trim();
|
|
155
|
+
if (!value) errors.push(`finding ${heading[1]}: field '${field}' is missing or empty`);
|
|
156
|
+
}
|
|
157
|
+
const tier = /^-\s*\*\*Tier:\*\*\s*(\S+)/m.exec(block)?.[1];
|
|
158
|
+
if (tier && !/^[123]$/.test(tier)) errors.push(`finding ${heading[1]}: tier '${tier}' must be 1, 2 or 3`);
|
|
159
|
+
const confidence = /^-\s*\*\*Confidence:\*\*\s*(\S+)/m.exec(block)?.[1]?.toLowerCase();
|
|
160
|
+
if (confidence && !/^(high|medium|low)$/.test(confidence)) {
|
|
161
|
+
errors.push(`finding ${heading[1]}: confidence '${confidence}' must be high, medium or low`);
|
|
162
|
+
}
|
|
163
|
+
const retrieved = /^-\s*\*\*Retrieved:\*\*\s*(\S+)/m.exec(block)?.[1];
|
|
164
|
+
if (retrieved && !isValidDate(retrieved)) {
|
|
165
|
+
errors.push(`finding ${heading[1]}: retrieved '${retrieved}' must be a valid date (YYYY-MM-DD)`);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return { errors, warnings, doc };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export interface ResearchState {
|
|
173
|
+
status: ResearchStatus;
|
|
174
|
+
path: string;
|
|
175
|
+
relPath: string;
|
|
176
|
+
topic: string;
|
|
177
|
+
question: string;
|
|
178
|
+
retrieved: string;
|
|
179
|
+
expires: string;
|
|
180
|
+
errors: string[];
|
|
181
|
+
warnings: string[];
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Format and expiry of one research file. Expiry is compared against today, never recomputed from the config. */
|
|
185
|
+
export function researchStatus(path: string, now = today()): ResearchState {
|
|
186
|
+
const base: ResearchState = {
|
|
187
|
+
status: "missing",
|
|
188
|
+
path,
|
|
189
|
+
relPath: relPath(path),
|
|
190
|
+
topic: basename(path, ".md"),
|
|
191
|
+
question: "",
|
|
192
|
+
retrieved: "",
|
|
193
|
+
expires: "",
|
|
194
|
+
errors: [],
|
|
195
|
+
warnings: [],
|
|
196
|
+
};
|
|
197
|
+
if (!existsSync(path)) return base;
|
|
198
|
+
|
|
199
|
+
const validation = validateResearchFile(path);
|
|
200
|
+
const meta = validation.doc?.meta ?? {};
|
|
201
|
+
const state: ResearchState = {
|
|
202
|
+
...base,
|
|
203
|
+
topic: typeof meta.topic === "string" ? meta.topic : base.topic,
|
|
204
|
+
question: typeof meta.question === "string" ? meta.question : "",
|
|
205
|
+
retrieved: typeof meta.retrieved === "string" ? meta.retrieved : "",
|
|
206
|
+
expires: typeof meta.expires === "string" ? meta.expires : "",
|
|
207
|
+
errors: validation.errors,
|
|
208
|
+
warnings: validation.warnings,
|
|
209
|
+
};
|
|
210
|
+
if (validation.errors.length) return { ...state, status: "invalid" };
|
|
211
|
+
if (isValidDate(state.expires) && state.expires < now) return { ...state, status: "expired" };
|
|
212
|
+
return { ...state, status: "valid" };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** A local research reference: `docs/research/<topic>.md`, optionally with `#anchor`. */
|
|
216
|
+
export function isLocalResearchRef(ref: string): boolean {
|
|
217
|
+
return /^docs\/research\/[A-Za-z0-9._-]+\.md(?:#.*)?$/.test(ref.trim());
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** A source is a web URL or a local research file. */
|
|
221
|
+
export function isValidSourceRef(ref: string): boolean {
|
|
222
|
+
return /^https?:\/\/\S+$/i.test(ref.trim()) || isLocalResearchRef(ref);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Absolute path of a local research reference, without the anchor. */
|
|
226
|
+
export function researchPath(ref: string): string {
|
|
227
|
+
return join(ROOT, ref.trim().split("#", 1)[0]);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** The `sources:` entries of a story, as written. */
|
|
231
|
+
export function sourceRefs(meta: Obj): string[] {
|
|
232
|
+
return Array.isArray(meta.sources)
|
|
233
|
+
? meta.sources.filter((value): value is string => typeof value === "string" && value.trim() !== "").map((value) => value.trim())
|
|
234
|
+
: [];
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* The gate: a story with one of the configured risks or labels needs research evidence before it leaves `review`.
|
|
239
|
+
* Returns the trigger (`risk 'high'`, `label 'security:oauth'`, or both), or null when the story is not gated.
|
|
240
|
+
* A label matches exactly or by prefix: `security` matches `security:oauth`.
|
|
241
|
+
*/
|
|
242
|
+
export function researchGateRequired(meta: Obj, cfg: ResearchConfig = loadResearchConfig()): string | null {
|
|
243
|
+
const triggers: string[] = [];
|
|
244
|
+
const risk = typeof meta.risk === "string" ? meta.risk.toLowerCase() : "";
|
|
245
|
+
if (risk && cfg.gate.risk.includes(risk)) triggers.push(`risk '${risk}'`);
|
|
246
|
+
|
|
247
|
+
const labels = Array.isArray(meta.labels)
|
|
248
|
+
? meta.labels.filter((value): value is string => typeof value === "string").map((value) => value.toLowerCase())
|
|
249
|
+
: [];
|
|
250
|
+
const matched = labels.find((label) =>
|
|
251
|
+
cfg.gate.labels.some((configured) => label === configured || label.startsWith(`${configured}:`)),
|
|
252
|
+
);
|
|
253
|
+
if (matched) triggers.push(`label '${matched}'`);
|
|
254
|
+
|
|
255
|
+
return triggers.length ? triggers.join(" / ") : null;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Hard-gate errors for the transition to `ready-for-human-review` or `ready`.
|
|
260
|
+
*
|
|
261
|
+
* A source counts when it is `https://…` or a local `docs/research/<topic>.md`; a local file must exist. Expiry is
|
|
262
|
+
* a warning elsewhere, never a gate error: an expired file still records where the evidence came from.
|
|
263
|
+
*/
|
|
264
|
+
export function researchGateErrors(meta: Obj, cfg: ResearchConfig = loadResearchConfig()): string[] {
|
|
265
|
+
const trigger = researchGateRequired(meta, cfg);
|
|
266
|
+
if (!trigger) return [];
|
|
267
|
+
|
|
268
|
+
const sources = sourceRefs(meta);
|
|
269
|
+
if (!sources.length) {
|
|
270
|
+
return [`${trigger} requires at least one 'sources:' entry before ready-for-human-review`];
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const valid = sources.filter(isValidSourceRef);
|
|
274
|
+
if (!valid.length) {
|
|
275
|
+
return [`${trigger} has no resolvable source – use https://… or docs/research/<topic>.md`];
|
|
276
|
+
}
|
|
277
|
+
if (valid.some((ref) => /^https?:\/\//i.test(ref))) return [];
|
|
278
|
+
|
|
279
|
+
const local = valid.filter(isLocalResearchRef);
|
|
280
|
+
const missing = local.filter((ref) => !existsSync(researchPath(ref)));
|
|
281
|
+
if (local.length && missing.length === local.length) {
|
|
282
|
+
return [`${trigger} has no resolvable source – missing: ${missing.join(", ")}`];
|
|
283
|
+
}
|
|
284
|
+
return [];
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
export interface ExpiredResearchRef {
|
|
288
|
+
ref: string;
|
|
289
|
+
path: string;
|
|
290
|
+
relPath: string;
|
|
291
|
+
expires: string;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** Local research references of a story whose expiry has passed. Advisory: verify prints them, the gate does not. */
|
|
295
|
+
export function expiredResearchRefs(meta: Obj, cfg: ResearchConfig = loadResearchConfig(), now = today()): ExpiredResearchRef[] {
|
|
296
|
+
void cfg;
|
|
297
|
+
const out: ExpiredResearchRef[] = [];
|
|
298
|
+
for (const ref of sourceRefs(meta).filter(isLocalResearchRef)) {
|
|
299
|
+
const state = researchStatus(researchPath(ref), now);
|
|
300
|
+
if (state.status === "expired") out.push({ ref, path: state.path, relPath: state.relPath, expires: state.expires });
|
|
301
|
+
}
|
|
302
|
+
return out;
|
|
303
|
+
}
|