kataster 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/.claude/agents/acceptance-tester.md +53 -0
- package/.claude/agents/code-reviewer.md +56 -0
- package/.claude/agents/domain-architect.md +40 -0
- package/.claude/agents/lead-dev.md +50 -0
- package/.claude/agents/product-owner.md +39 -0
- package/.claude/agents/repo-analyst.md +78 -0
- package/.claude/agents/requirements-engineer.md +44 -0
- package/.claude/agents/ux-designer.md +64 -0
- package/.claude/hooks/bash-guard.ts +92 -0
- package/.claude/hooks/code-check.ts +37 -0
- package/.claude/hooks/path-guard.ts +34 -0
- package/.claude/hooks/path-rules.ts +66 -0
- package/.claude/hooks/project-check.ts +108 -0
- package/.claude/hooks/protect-files.ts +55 -0
- package/.claude/hooks/session-start.ts +213 -0
- package/.claude/hooks/stop-gate.ts +56 -0
- package/.claude/hooks/validate-docs.ts +58 -0
- package/.claude/lib/architecture.ts +736 -0
- package/.claude/lib/backlog-github.ts +454 -0
- package/.claude/lib/backlog-jira.ts +347 -0
- package/.claude/lib/backlog-sync.ts +42 -0
- package/.claude/lib/backlog.ts +396 -0
- package/.claude/lib/bazel.ts +212 -0
- package/.claude/lib/config.ts +122 -0
- package/.claude/lib/discovery.ts +821 -0
- package/.claude/lib/engineering.ts +406 -0
- package/.claude/lib/nx.ts +129 -0
- package/.claude/lib/repo.ts +1194 -0
- package/.claude/lib/toolgraph.ts +74 -0
- package/.claude/lib/turbo.ts +166 -0
- package/.claude/package-lock.json +427 -0
- package/.claude/package.json +21 -0
- package/.claude/settings.json +82 -0
- package/.claude/skills/adopt/SKILL.md +283 -0
- package/.claude/skills/adopt/scripts/boundary-check.ts +156 -0
- package/.claude/skills/adopt/scripts/package-doc.ts +71 -0
- package/.claude/skills/adopt/scripts/repo-map.ts +123 -0
- package/.claude/skills/adopt/scripts/rule-doc.ts +80 -0
- package/.claude/skills/architect/ADR-FORMAT.md +97 -0
- package/.claude/skills/architect/SKILL.md +64 -0
- package/.claude/skills/architect/TECH-STACK.md +21 -0
- package/.claude/skills/backlog/SKILL.md +198 -0
- package/.claude/skills/backlog/scripts/backlog-pull.ts +45 -0
- package/.claude/skills/backlog/scripts/backlog-push.ts +38 -0
- package/.claude/skills/backlog/scripts/backlog-status.ts +32 -0
- package/.claude/skills/backlog/scripts/jira-import.ts +40 -0
- package/.claude/skills/backlog/scripts/jira-plan.ts +26 -0
- package/.claude/skills/backlog/scripts/jira-record.ts +41 -0
- package/.claude/skills/bootstrap/SKILL.md +119 -0
- package/.claude/skills/codebase-design/DEEPENING.md +37 -0
- package/.claude/skills/codebase-design/DESIGN-IT-TWICE.md +44 -0
- package/.claude/skills/codebase-design/SKILL.md +114 -0
- package/.claude/skills/domain-model/CONTEXT-FORMAT.md +47 -0
- package/.claude/skills/domain-model/SKILL.md +64 -0
- package/.claude/skills/domain-model/templates/data-model.md +29 -0
- package/.claude/skills/engineering-conventions/SKILL.md +31 -0
- package/.claude/skills/event-storming/SKILL.md +44 -0
- package/.claude/skills/event-storming/schema/events.schema.json +124 -0
- package/.claude/skills/event-storming/scripts/render-event-storming.ts +11 -0
- package/.claude/skills/event-storming/scripts/validate-events.ts +11 -0
- package/.claude/skills/event-storming/templates/events.example.yaml +91 -0
- package/.claude/skills/feature/SKILL.md +86 -0
- package/.claude/skills/feature/scripts/intake.ts +356 -0
- package/.claude/skills/grill-me/SKILL.md +7 -0
- package/.claude/skills/grill-with-docs/SKILL.md +7 -0
- package/.claude/skills/grilling/SKILL.md +30 -0
- package/.claude/skills/groom-backlog/SKILL.md +93 -0
- package/.claude/skills/groom-backlog/scripts/backlog-health.ts +238 -0
- package/.claude/skills/implement/SKILL.md +129 -0
- package/.claude/skills/implement/scripts/affected.ts +245 -0
- package/.claude/skills/implement/scripts/check-commands.ts +37 -0
- package/.claude/skills/implement/scripts/check-language.ts +15 -0
- package/.claude/skills/implement/scripts/check-scenarios.ts +59 -0
- package/.claude/skills/implement/scripts/next-story.ts +23 -0
- package/.claude/skills/implement/scripts/story-context.ts +135 -0
- package/.claude/skills/implement/scripts/story-status.ts +47 -0
- package/.claude/skills/implement/scripts/verify.ts +92 -0
- package/.claude/skills/improve-codebase-architecture/HTML-REPORT.md +123 -0
- package/.claude/skills/improve-codebase-architecture/SKILL.md +71 -0
- package/.claude/skills/retro/SKILL.md +53 -0
- package/.claude/skills/review-stories/SKILL.md +31 -0
- package/.claude/skills/shape/SKILL.md +193 -0
- package/.claude/skills/tdd/SKILL.md +38 -0
- package/.claude/skills/tdd/mocking.md +59 -0
- package/.claude/skills/tdd/tests.md +77 -0
- package/.claude/skills/user-stories/SKILL.md +138 -0
- package/.claude/skills/user-stories/schema/story.schema.json +39 -0
- package/.claude/skills/user-stories/scripts/render-backlog.ts +4 -0
- package/.claude/skills/user-stories/scripts/validate-stories.ts +13 -0
- package/.claude/skills/user-stories/templates/story-template.md +38 -0
- package/.claude/skills/ux-design/SKILL.md +107 -0
- package/.claude/skills/writing-for-agents/SKILL-MECHANICS.md +22 -0
- package/.claude/skills/writing-for-agents/SKILL.md +81 -0
- package/.claude/tsconfig.json +15 -0
- package/.opencode/agents/acceptance-tester.md +64 -0
- package/.opencode/agents/code-reviewer.md +69 -0
- package/.opencode/agents/domain-architect.md +59 -0
- package/.opencode/agents/lead-dev.md +67 -0
- package/.opencode/agents/product-owner.md +59 -0
- package/.opencode/agents/repo-analyst.md +86 -0
- package/.opencode/agents/requirements-engineer.md +58 -0
- package/.opencode/agents/ux-designer.md +83 -0
- package/.opencode/commands/adopt.md +7 -0
- package/.opencode/commands/backlog-sync.md +7 -0
- package/.opencode/commands/bootstrap.md +7 -0
- package/.opencode/commands/event-storming.md +7 -0
- package/.opencode/commands/feature.md +7 -0
- package/.opencode/commands/grill-me.md +7 -0
- package/.opencode/commands/grill-with-docs.md +7 -0
- package/.opencode/commands/groom-backlog.md +7 -0
- package/.opencode/commands/implement.md +7 -0
- package/.opencode/commands/improve-codebase-architecture.md +7 -0
- package/.opencode/commands/retro.md +7 -0
- package/.opencode/commands/review-stories.md +7 -0
- package/.opencode/commands/shape.md +7 -0
- package/.opencode/opencode.jsonc +15 -0
- package/.opencode/plugins/kataster/index.ts +283 -0
- package/AGENTS.md +21 -0
- package/CHANGELOG.md +82 -0
- package/LICENSE +21 -0
- package/README.md +324 -2
- package/bin/kataster.mjs +7 -0
- package/extras/skills/shadcn/SKILL.md +277 -0
- package/extras/skills/shadcn/agents/openai.yml +5 -0
- package/extras/skills/shadcn/assets/shadcn-small.png +0 -0
- package/extras/skills/shadcn/assets/shadcn.png +0 -0
- package/extras/skills/shadcn/cli.md +290 -0
- package/extras/skills/shadcn/customization.md +209 -0
- package/extras/skills/shadcn/evals/evals.json +77 -0
- package/extras/skills/shadcn/mcp.md +105 -0
- package/extras/skills/shadcn/registry.md +277 -0
- package/extras/skills/shadcn/rules/base-vs-radix.md +306 -0
- package/extras/skills/shadcn/rules/chat.md +250 -0
- package/extras/skills/shadcn/rules/composition.md +213 -0
- package/extras/skills/shadcn/rules/forms.md +192 -0
- package/extras/skills/shadcn/rules/icons.md +101 -0
- package/extras/skills/shadcn/rules/styling.md +185 -0
- package/guide/README.md +9 -0
- package/guide/brownfield.md +242 -0
- package/guide/concepts.md +109 -0
- package/guide/greenfield.md +84 -0
- package/guide/install.md +144 -0
- package/guide/release.md +62 -0
- package/package.json +56 -4
- package/src/cli.mjs +302 -0
- package/src/detect.mjs +237 -0
- package/src/install.mjs +427 -0
- package/src/repo.mjs +129 -0
- package/src/util.mjs +110 -0
- package/templates/CONTEXT.md +21 -0
- package/templates/conventions.md +63 -0
- package/templates/docs/adr/README.md +7 -0
- package/templates/docs/architecture/data-model.md +31 -0
- package/templates/docs/domain/context-map.md +21 -0
- package/templates/docs/domain/events.yaml +23 -0
- package/templates/docs/product/initiatives/README.md +41 -0
- package/templates/docs/product/ux-guidelines.md +27 -0
- package/templates/docs/product/vision.md +36 -0
- package/templates/docs/reviews/.gitkeep +0 -0
- package/templates/docs/stories/OPEN_QUESTIONS.md +8 -0
- package/templates/docs/stories/README.md +26 -0
- package/templates/docs/workflow.md +135 -0
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The backlog provider contract and its local representation.
|
|
3
|
+
*
|
|
4
|
+
* The story file is always the same Markdown document: YAML frontmatter plus the domain body. In `local` mode those
|
|
5
|
+
* files *are* the backlog. With `github` or `jira`, the tracker is the source of truth and `docs/stories/` is the
|
|
6
|
+
* local mirror:
|
|
7
|
+
*
|
|
8
|
+
* - a story keeps its stable `ST-NNN` id (tests are titled `ST-NNN: <scenario>`) and gets
|
|
9
|
+
* `external: { provider, key, url }`
|
|
10
|
+
* - the issue body carries the story body plus a machine-readable `<!-- kataster:story … -->` block, so a pull can
|
|
11
|
+
* restore every field even where the tracker has no field for it
|
|
12
|
+
* - facets the tracker *does* have (status, priority, type, context, size, risk) also become managed labels (and,
|
|
13
|
+
* for Jira, the priority field); on a pull they win over the machine block
|
|
14
|
+
*
|
|
15
|
+
* This module is deterministic and does no network I/O. `backlog-github.ts` talks to `gh`, `backlog-jira.ts`
|
|
16
|
+
* prepares and consumes the MCP payloads, and the skills own the Jira tool calls.
|
|
17
|
+
*/
|
|
18
|
+
import { createHash } from "node:crypto";
|
|
19
|
+
import { copyFileSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
20
|
+
import { dirname, join } from "node:path";
|
|
21
|
+
import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
|
|
22
|
+
import * as d from "./discovery.ts";
|
|
23
|
+
import { isManagedLabel, labelPrefix, type BacklogConfig } from "./config.ts";
|
|
24
|
+
|
|
25
|
+
// --------------------------------------------------------------------------
|
|
26
|
+
// Types
|
|
27
|
+
// --------------------------------------------------------------------------
|
|
28
|
+
export interface ExternalRef {
|
|
29
|
+
provider: "github" | "jira";
|
|
30
|
+
key: string;
|
|
31
|
+
url?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface IssuePayload {
|
|
35
|
+
title: string;
|
|
36
|
+
body: string;
|
|
37
|
+
labels: string[];
|
|
38
|
+
status: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface RemoteIssue {
|
|
42
|
+
key: string;
|
|
43
|
+
url?: string;
|
|
44
|
+
title: string;
|
|
45
|
+
body: string;
|
|
46
|
+
labels: string[];
|
|
47
|
+
/** GitHub: open/closed. Jira leaves this undefined. */
|
|
48
|
+
state?: "open" | "closed";
|
|
49
|
+
/** Jira: the tracker status name (e.g. "In Progress"). */
|
|
50
|
+
status?: string;
|
|
51
|
+
/** Jira: the tracker priority name (e.g. "High"). */
|
|
52
|
+
priority?: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Jira labels allow only letters, numbers and a few separators. */
|
|
56
|
+
const sanitizeJiraLabel = (label: string): string => label.replace(/[^A-Za-z0-9_.-]/g, "-");
|
|
57
|
+
|
|
58
|
+
const list = (v: unknown): string[] => (Array.isArray(v) ? v.filter((x): x is string => typeof x === "string") : []);
|
|
59
|
+
const META_RE =
|
|
60
|
+
/(?:<!--\s*kataster:story\s*\r?\n([\s\S]*?)\r?\n\s*-->|```kataster-story[ \t]*\r?\n([\s\S]*?)\r?\n```)/;
|
|
61
|
+
const ID_PREFIX_RE = /^(ST-\d{3,})\s*:\s*/;
|
|
62
|
+
|
|
63
|
+
// --------------------------------------------------------------------------
|
|
64
|
+
// External reference in the story frontmatter
|
|
65
|
+
// --------------------------------------------------------------------------
|
|
66
|
+
export function externalOf(story: d.Story): ExternalRef | null {
|
|
67
|
+
const ext = story.meta.external;
|
|
68
|
+
if (!ext || typeof ext !== "object") return null;
|
|
69
|
+
if (typeof ext.key !== "string" || !ext.key) return null;
|
|
70
|
+
if (ext.provider !== "github" && ext.provider !== "jira") return null;
|
|
71
|
+
return { provider: ext.provider, key: ext.key, url: typeof ext.url === "string" && ext.url ? ext.url : undefined };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Writes (or replaces) the `external:` block without re-formatting the rest of the frontmatter. */
|
|
75
|
+
export function setExternal(story: d.Story, external: ExternalRef): void {
|
|
76
|
+
const text = readFileSync(story.path, "utf8");
|
|
77
|
+
const end = text.indexOf("\n---", 4);
|
|
78
|
+
if (end === -1) throw new Error(`${d.rel(story.path)}: frontmatter is not closed with '---'`);
|
|
79
|
+
let front = text.slice(4, end);
|
|
80
|
+
front = front.replace(/\nexternal:\n(?:[ \t]+.*\n)*/g, "\n");
|
|
81
|
+
const block =
|
|
82
|
+
"external:\n" +
|
|
83
|
+
` provider: ${external.provider}\n` +
|
|
84
|
+
` key: ${JSON.stringify(external.key)}\n` +
|
|
85
|
+
` url: ${JSON.stringify(external.url ?? "")}\n`;
|
|
86
|
+
front = `${front.trimEnd()}\n${block}`;
|
|
87
|
+
writeFileSync(story.path, `---\n${front}---${text.slice(end + 4)}`, "utf8");
|
|
88
|
+
story.meta.external = { ...external };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// --------------------------------------------------------------------------
|
|
92
|
+
// Labels
|
|
93
|
+
// --------------------------------------------------------------------------
|
|
94
|
+
/** Managed labels for the facets the tracker can display and filter. */
|
|
95
|
+
export function managedLabels(meta: d.Obj, cfg: BacklogConfig): string[] {
|
|
96
|
+
const prefix = labelPrefix(cfg);
|
|
97
|
+
const labels = [
|
|
98
|
+
prefix,
|
|
99
|
+
`${prefix}-status-${String(meta.status ?? "draft").toLowerCase()}`,
|
|
100
|
+
`${prefix}-priority-${String(meta.priority ?? "should").toLowerCase()}`,
|
|
101
|
+
`${prefix}-type-${String(meta.type ?? "story").toLowerCase()}`,
|
|
102
|
+
];
|
|
103
|
+
if (meta.context) labels.push(`${prefix}-context-${String(meta.context).toLowerCase()}`);
|
|
104
|
+
if (meta.size) labels.push(`${prefix}-size-${String(meta.size).toLowerCase()}`);
|
|
105
|
+
if (meta.risk) labels.push(`${prefix}-risk-${String(meta.risk).toLowerCase()}`);
|
|
106
|
+
for (const label of list(meta.labels)) {
|
|
107
|
+
if (isManagedLabel(label, prefix)) continue;
|
|
108
|
+
// Jira labels allow only letters, numbers and a few separators. The original label is in the machine block;
|
|
109
|
+
// the sanitized one is only there to filter and search.
|
|
110
|
+
labels.push(cfg.provider === "jira" ? sanitizeJiraLabel(label) : label);
|
|
111
|
+
}
|
|
112
|
+
return [...new Set(labels)];
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** The facet value from a managed label, or undefined. */
|
|
116
|
+
function labelFacet(labels: string[], prefix: string, facet: string): string | undefined {
|
|
117
|
+
const head = `${prefix}-${facet}-`;
|
|
118
|
+
return labels.find((label) => label.startsWith(head))?.slice(head.length);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// --------------------------------------------------------------------------
|
|
122
|
+
// Story ↔ issue
|
|
123
|
+
// --------------------------------------------------------------------------
|
|
124
|
+
/** The machine-readable block appended to the issue body. Jira Cloud drops HTML comments in ADF, so it gets a code
|
|
125
|
+
* block there; GitHub keeps the block out of the rendered issue with an HTML comment. */
|
|
126
|
+
export function metaBlock(meta: d.Obj, provider: "local" | "github" | "jira"): string {
|
|
127
|
+
const copy: d.Obj = { ...meta };
|
|
128
|
+
delete copy.external;
|
|
129
|
+
const yaml = stringifyYaml(copy, { lineWidth: 0 }).trimEnd();
|
|
130
|
+
return provider === "jira"
|
|
131
|
+
? `\`\`\`kataster-story\n${yaml}\n\`\`\``
|
|
132
|
+
: `<!-- kataster:story\n${yaml}\n-->`;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Renders the issue for a story. The title keeps the stable id so test titles stay findable. */
|
|
136
|
+
export function renderIssue(story: d.Story, cfg: BacklogConfig): IssuePayload {
|
|
137
|
+
const meta = { ...story.meta };
|
|
138
|
+
delete meta.external;
|
|
139
|
+
return {
|
|
140
|
+
title: `${d.storyId(story)}: ${String(meta.title ?? "")}`.trim(),
|
|
141
|
+
body: `${story.body.trimEnd()}\n\n${metaBlock(meta, cfg.provider)}\n`,
|
|
142
|
+
labels: managedLabels(story.meta, cfg),
|
|
143
|
+
status: String(story.meta.status ?? "draft"),
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export interface ParsedStory {
|
|
148
|
+
id: string;
|
|
149
|
+
meta: d.Obj;
|
|
150
|
+
body: string;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Rebuilds a story from an issue. The tracker's fields win where they exist; the machine block fills the rest.
|
|
155
|
+
* `fallbackId` is used for issues that were not created by Kataster and carry no `ST-NNN` in the title.
|
|
156
|
+
*/
|
|
157
|
+
export function parseIssue(
|
|
158
|
+
issue: RemoteIssue,
|
|
159
|
+
cfg: BacklogConfig,
|
|
160
|
+
{ contexts = [], fallbackId = "" }: { contexts?: string[]; fallbackId?: string } = {},
|
|
161
|
+
): ParsedStory {
|
|
162
|
+
const match = META_RE.exec(issue.body);
|
|
163
|
+
let meta: d.Obj = {};
|
|
164
|
+
if (match) {
|
|
165
|
+
try {
|
|
166
|
+
const parsed = parseYaml(match[1] ?? match[2]);
|
|
167
|
+
if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)) meta = parsed as d.Obj;
|
|
168
|
+
} catch {
|
|
169
|
+
// a broken machine block is not fatal: the title, labels and body still carry the story
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
const prefix = labelPrefix(cfg);
|
|
173
|
+
const labels = list(issue.labels);
|
|
174
|
+
const body = (match ? issue.body.replace(META_RE, "") : issue.body).trim();
|
|
175
|
+
|
|
176
|
+
const id = String(meta.id ?? ID_PREFIX_RE.exec(issue.title)?.[1] ?? fallbackId).toUpperCase();
|
|
177
|
+
const title = issue.title.replace(ID_PREFIX_RE, "").trim() || String(meta.title ?? id);
|
|
178
|
+
const type = labelFacet(labels, prefix, "type") ?? meta.type ?? "story";
|
|
179
|
+
const contextLabel = labelFacet(labels, prefix, "context");
|
|
180
|
+
const context =
|
|
181
|
+
(contextLabel ? contexts.find((known) => known.toLowerCase() === contextLabel) : undefined) ??
|
|
182
|
+
(typeof meta.context === "string" ? meta.context : "");
|
|
183
|
+
const priority =
|
|
184
|
+
(cfg.provider === "jira" ? reversePriority(issue.priority, cfg) : undefined) ??
|
|
185
|
+
labelFacet(labels, prefix, "priority") ??
|
|
186
|
+
meta.priority ??
|
|
187
|
+
"should";
|
|
188
|
+
const size = labelFacet(labels, prefix, "size")?.toUpperCase() ?? meta.size ?? null;
|
|
189
|
+
const risk = labelFacet(labels, prefix, "risk") ?? meta.risk ?? null;
|
|
190
|
+
const status = statusFromIssue(issue, labels, meta, cfg);
|
|
191
|
+
const userLabels = cfg.provider === "jira" ? jiraUserLabels(labels, prefix, meta) : labels.filter((label) => !isManagedLabel(label, prefix));
|
|
192
|
+
|
|
193
|
+
const clean: d.Obj = {
|
|
194
|
+
...meta,
|
|
195
|
+
id,
|
|
196
|
+
title,
|
|
197
|
+
type,
|
|
198
|
+
context,
|
|
199
|
+
priority,
|
|
200
|
+
size,
|
|
201
|
+
risk,
|
|
202
|
+
events: list(meta.events),
|
|
203
|
+
depends_on: list(meta.depends_on),
|
|
204
|
+
labels: userLabels,
|
|
205
|
+
status,
|
|
206
|
+
};
|
|
207
|
+
if (cfg.provider !== "local") {
|
|
208
|
+
clean.external = { provider: cfg.provider, key: issue.key, ...(issue.url ? { url: issue.url } : {}) };
|
|
209
|
+
}
|
|
210
|
+
// Keep a stable key order: identity, classification, workflow, links, then the external reference.
|
|
211
|
+
const ordered: d.Obj = {
|
|
212
|
+
id: clean.id,
|
|
213
|
+
title: clean.title,
|
|
214
|
+
type: clean.type,
|
|
215
|
+
context: clean.context,
|
|
216
|
+
priority: clean.priority,
|
|
217
|
+
size: clean.size,
|
|
218
|
+
risk: clean.risk,
|
|
219
|
+
events: clean.events,
|
|
220
|
+
depends_on: clean.depends_on,
|
|
221
|
+
labels: clean.labels,
|
|
222
|
+
status: clean.status,
|
|
223
|
+
...(clean.external ? { external: clean.external } : {}),
|
|
224
|
+
};
|
|
225
|
+
return { id, meta: ordered, body };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function reversePriority(priority: string | undefined, cfg: BacklogConfig): string | undefined {
|
|
229
|
+
if (!priority) return undefined;
|
|
230
|
+
return Object.entries(cfg.jira.priorityMap).find(([, name]) => name.toLowerCase() === priority.toLowerCase())?.[0];
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Jira labels are sanitized, so the original labels live in the machine block. The issue stays authoritative for
|
|
235
|
+
* additions and removals: an original label survives only when its sanitized form is still on the issue, and a label
|
|
236
|
+
* added in Jira is kept as it is. An empty label list is treated as "labels were not part of the fetch" and leaves the
|
|
237
|
+
* story's labels alone.
|
|
238
|
+
*/
|
|
239
|
+
function jiraUserLabels(labels: string[], prefix: string, meta: d.Obj): string[] {
|
|
240
|
+
const fromIssue = labels.filter((label) => !isManagedLabel(label, prefix));
|
|
241
|
+
const original = list(meta.labels);
|
|
242
|
+
if (!original.length) return fromIssue;
|
|
243
|
+
if (!fromIssue.length) return original;
|
|
244
|
+
const known = new Map(original.map((label) => [sanitizeJiraLabel(label), label]));
|
|
245
|
+
return [
|
|
246
|
+
...original.filter((label) => fromIssue.includes(sanitizeJiraLabel(label))),
|
|
247
|
+
...fromIssue.filter((label) => !known.has(label)),
|
|
248
|
+
];
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function statusFromIssue(issue: RemoteIssue, labels: string[], meta: d.Obj, cfg: BacklogConfig): string {
|
|
252
|
+
const prefix = labelPrefix(cfg);
|
|
253
|
+
const fromLabel = labelFacet(labels, prefix, "status");
|
|
254
|
+
const known = (value: string | undefined): value is string =>
|
|
255
|
+
value !== undefined && (d.STATUSES as readonly string[]).includes(value);
|
|
256
|
+
|
|
257
|
+
if (cfg.provider === "github") {
|
|
258
|
+
if (issue.state === "closed") return fromLabel === "superseded" ? "superseded" : "done";
|
|
259
|
+
return known(fromLabel) ? fromLabel : known(meta.status) ? meta.status : "draft";
|
|
260
|
+
}
|
|
261
|
+
if (cfg.provider === "jira" && issue.status) {
|
|
262
|
+
const local = Object.entries(cfg.jira.statusMap).find(([, name]) => name.toLowerCase() === issue.status!.toLowerCase())?.[0];
|
|
263
|
+
if (known(local)) return local;
|
|
264
|
+
}
|
|
265
|
+
return known(fromLabel) ? fromLabel : known(meta.status) ? meta.status : "draft";
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// --------------------------------------------------------------------------
|
|
269
|
+
// Files
|
|
270
|
+
// --------------------------------------------------------------------------
|
|
271
|
+
export function slugify(title: string): string {
|
|
272
|
+
const slug = title
|
|
273
|
+
.toLowerCase()
|
|
274
|
+
.replace(/ß/g, "ss")
|
|
275
|
+
.normalize("NFKD")
|
|
276
|
+
.replace(/\p{M}/gu, "")
|
|
277
|
+
.replace(/[^\p{Letter}\p{Number}]+/gu, "-")
|
|
278
|
+
.replace(/^-+|-+$/g, "")
|
|
279
|
+
.slice(0, 60)
|
|
280
|
+
.replace(/-+$/g, "");
|
|
281
|
+
return slug || "story";
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
export function nextStoryId(): string {
|
|
285
|
+
const ids = d.loadStories().parsed.map(d.storyId).filter(Boolean);
|
|
286
|
+
const max = Math.max(0, ...ids.map((id) => Number(id.slice(3)) || 0));
|
|
287
|
+
return `ST-${String(max + 1).padStart(3, "0")}`;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
export function bumpStoryId(id: string): string {
|
|
291
|
+
return `ST-${String((Number(id.slice(3)) || 0) + 1).padStart(3, "0")}`;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** Writes a story file, reusing the existing path when the id is already mirrored. */
|
|
295
|
+
export function writeStory(id: string, meta: d.Obj, body: string, existing?: d.Story): string {
|
|
296
|
+
const { external, ...rest } = meta;
|
|
297
|
+
const clean: d.Obj = { ...rest, id, ...(external ? { external } : {}) };
|
|
298
|
+
const text = `---\n${stringifyYaml(clean, { lineWidth: 0 }).trimEnd()}\n---\n\n${body.trimEnd()}\n`;
|
|
299
|
+
const path = existing?.path ?? join(d.STORIES_DIR, `${id}-${slugify(String(clean.title ?? "story"))}.md`);
|
|
300
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
301
|
+
writeFileSync(path, text, "utf8");
|
|
302
|
+
return path;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** Backs up a mirror story before a tracker pull overwrites it. */
|
|
306
|
+
export function backupStory(story: d.Story): string {
|
|
307
|
+
const stamp = new Date().toISOString().replaceAll(":", "-").replace(/\..+$/, "");
|
|
308
|
+
const dir = join(d.ROOT, ".kataster", "backup", "backlog", stamp);
|
|
309
|
+
mkdirSync(dir, { recursive: true });
|
|
310
|
+
const target = join(dir, story.path.split(/[\\/]/).pop() ?? "story.md");
|
|
311
|
+
copyFileSync(story.path, target);
|
|
312
|
+
return d.rel(target);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** Content hash of a story file, and the same for an issue payload. */
|
|
316
|
+
export function contentHash(value: string): string {
|
|
317
|
+
return createHash("sha256").update(value).digest("hex").slice(0, 16);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
export function storyHash(story: d.Story): string {
|
|
321
|
+
return contentHash(readFileSync(story.path, "utf8"));
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
export function payloadHash(payload: IssuePayload): string {
|
|
325
|
+
return contentHash(JSON.stringify({
|
|
326
|
+
title: payload.title,
|
|
327
|
+
body: payload.body,
|
|
328
|
+
labels: [...payload.labels].sort(),
|
|
329
|
+
status: payload.status,
|
|
330
|
+
}));
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** The story's payload as it is represented remotely. */
|
|
334
|
+
export function remotePayload(issue: RemoteIssue, cfg: BacklogConfig): IssuePayload {
|
|
335
|
+
const parsed = parseIssue(issue, cfg);
|
|
336
|
+
return {
|
|
337
|
+
title: issue.title,
|
|
338
|
+
body: issue.body,
|
|
339
|
+
labels: [...issue.labels].sort(),
|
|
340
|
+
status: parsed.meta.status,
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// --------------------------------------------------------------------------
|
|
345
|
+
// Sync state (`.claude/state/backlog-sync.json`)
|
|
346
|
+
// --------------------------------------------------------------------------
|
|
347
|
+
export interface SyncEntry {
|
|
348
|
+
key: string;
|
|
349
|
+
url?: string;
|
|
350
|
+
/** Hash of the local story file at the last successful sync. */
|
|
351
|
+
localHash: string;
|
|
352
|
+
/** Hash of the remote issue payload at the last successful sync (empty until the first pull). */
|
|
353
|
+
remoteHash: string;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
export interface SyncState {
|
|
357
|
+
provider: string;
|
|
358
|
+
updatedAt: string;
|
|
359
|
+
stories: Record<string, SyncEntry>;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
const STATE_FILE = join(d.ROOT, ".claude", "state", "backlog-sync.json");
|
|
363
|
+
|
|
364
|
+
export function readSyncState(): SyncState {
|
|
365
|
+
try {
|
|
366
|
+
const raw = JSON.parse(readFileSync(STATE_FILE, "utf8")) as Partial<SyncState>;
|
|
367
|
+
return {
|
|
368
|
+
provider: typeof raw.provider === "string" ? raw.provider : "local",
|
|
369
|
+
updatedAt: typeof raw.updatedAt === "string" ? raw.updatedAt : "",
|
|
370
|
+
stories: raw.stories !== null && typeof raw.stories === "object" ? (raw.stories as Record<string, SyncEntry>) : {},
|
|
371
|
+
};
|
|
372
|
+
} catch {
|
|
373
|
+
return { provider: "local", updatedAt: "", stories: {} };
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
export function writeSyncState(state: SyncState): void {
|
|
378
|
+
mkdirSync(dirname(STATE_FILE), { recursive: true });
|
|
379
|
+
writeFileSync(STATE_FILE, `${JSON.stringify({ ...state, updatedAt: new Date().toISOString() }, null, 2)}\n`, "utf8");
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/** The state for one provider; a provider switch starts from scratch instead of comparing unrelated hashes. */
|
|
383
|
+
export function readProviderState(provider: string): SyncState {
|
|
384
|
+
const state = readSyncState();
|
|
385
|
+
return state.provider === provider ? state : { provider, updatedAt: "", stories: {} };
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/** Everything the sync needs from the event storming: bounded-context IDs for label round-trips. */
|
|
389
|
+
export function knownContexts(): string[] {
|
|
390
|
+
const model = d.loadEventsModel();
|
|
391
|
+
if (!model) return [];
|
|
392
|
+
return ((model.bounded_contexts ?? []) as d.Obj[])
|
|
393
|
+
.map((bc) => bc.id)
|
|
394
|
+
.filter((id): id is string => typeof id === "string");
|
|
395
|
+
}
|
|
396
|
+
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bazel adapter.
|
|
3
|
+
*
|
|
4
|
+
* A Bazel repository has no manifests to read: its packages are directories with a `BUILD`/`BUILD.bazel`
|
|
5
|
+
* file, and its dependency edges are the `deps` attributes inside those files. `repo-map.ts` uses the BUILD
|
|
6
|
+
* files directly – no Bazel server, no toolchain, no network – which keeps the map deterministic and fast
|
|
7
|
+
* even on a machine that cannot build the repository.
|
|
8
|
+
*
|
|
9
|
+
* `bazel query 'deps(//...)'` would return the *target* closure instead; the package-level `deps` attributes
|
|
10
|
+
* are what a slice needs and what the tool itself uses to decide visibility.
|
|
11
|
+
*
|
|
12
|
+
* Edge direction: `{ from, to }` means **from depends on to**.
|
|
13
|
+
*
|
|
14
|
+
* Node >= 22.18, erasable TypeScript only, no runtime dependency.
|
|
15
|
+
*/
|
|
16
|
+
import { readFileSync, readdirSync } from "node:fs";
|
|
17
|
+
import { join, relative } from "node:path";
|
|
18
|
+
import type { GraphPackage, ToolGraph } from "./toolgraph.ts";
|
|
19
|
+
|
|
20
|
+
const IGNORED = new Set([
|
|
21
|
+
"node_modules", "dist", "build", "out", "coverage", ".git", ".claude", ".opencode", ".kataster",
|
|
22
|
+
"vendor", "target", "tmp", "temp",
|
|
23
|
+
]);
|
|
24
|
+
const BUILD_FILES = new Set(["BUILD", "BUILD.bazel"]);
|
|
25
|
+
const MAX_BUILD_FILES = 2000;
|
|
26
|
+
|
|
27
|
+
/** `./apps/a/` → `apps/a`; `""` and `"."` → `"."`. */
|
|
28
|
+
function normalizePackagePath(path: string): string {
|
|
29
|
+
const trimmed = path.replace(/\\/g, "/").replace(/^\.\//, "").replace(/\/+$/, "");
|
|
30
|
+
return trimmed === "" || trimmed === "." ? "." : trimmed;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface BuildFile {
|
|
34
|
+
/** Repository-relative directory of the BUILD file. */
|
|
35
|
+
path: string;
|
|
36
|
+
text: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Every BUILD/BUILD.bazel directory, sorted, capped so a huge monorepo stays fast. */
|
|
40
|
+
export function findBuildFiles(root: string, limit = MAX_BUILD_FILES): { path: string; file: string }[] {
|
|
41
|
+
const out: { path: string; file: string }[] = [];
|
|
42
|
+
const walk = (dir: string, depth: number): void => {
|
|
43
|
+
if (out.length >= limit || depth > 24) return;
|
|
44
|
+
let entries;
|
|
45
|
+
try {
|
|
46
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
47
|
+
} catch {
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
for (const entry of entries) {
|
|
51
|
+
if (entry.isDirectory()) {
|
|
52
|
+
if (IGNORED.has(entry.name) || entry.name.startsWith(".") || entry.name.startsWith("bazel-")) continue;
|
|
53
|
+
walk(join(dir, entry.name), depth + 1);
|
|
54
|
+
} else if (entry.isFile() && BUILD_FILES.has(entry.name)) {
|
|
55
|
+
out.push({ path: dir, file: entry.name });
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
walk(root, 0);
|
|
60
|
+
return out.sort((a, b) => a.path.localeCompare(b.path));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Removes `#` comments without touching `#` inside string literals. */
|
|
64
|
+
function stripComments(text: string): string {
|
|
65
|
+
let out = "";
|
|
66
|
+
let quote = "";
|
|
67
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
68
|
+
const c = text[i];
|
|
69
|
+
if (quote) {
|
|
70
|
+
out += c;
|
|
71
|
+
if (c === "\\") {
|
|
72
|
+
out += text[i + 1] ?? "";
|
|
73
|
+
i += 1;
|
|
74
|
+
} else if (c === quote) {
|
|
75
|
+
quote = "";
|
|
76
|
+
}
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
if (c === '"' || c === "'") {
|
|
80
|
+
quote = c;
|
|
81
|
+
out += c;
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
if (c === "#") {
|
|
85
|
+
while (i < text.length && text[i] !== "\n") i += 1;
|
|
86
|
+
out += "\n";
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
out += c;
|
|
90
|
+
}
|
|
91
|
+
return out;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** The bracketed value of every `attr = …` assignment; understands `select({…})` and nested lists. */
|
|
95
|
+
export function attributeValues(text: string, attribute: string): string[] {
|
|
96
|
+
const out: string[] = [];
|
|
97
|
+
const cleaned = stripComments(text);
|
|
98
|
+
const re = new RegExp(`\\b${attribute}\\s*=\\s*`, "g");
|
|
99
|
+
let match: RegExpExecArray | null;
|
|
100
|
+
while ((match = re.exec(cleaned))) {
|
|
101
|
+
let i = re.lastIndex;
|
|
102
|
+
while (i < cleaned.length && !"([{".includes(cleaned[i])) {
|
|
103
|
+
if (cleaned[i] === "\n" || cleaned[i] === ";") break;
|
|
104
|
+
i += 1;
|
|
105
|
+
}
|
|
106
|
+
if (i >= cleaned.length || !"([{".includes(cleaned[i])) continue;
|
|
107
|
+
|
|
108
|
+
const start = i;
|
|
109
|
+
const stack: string[] = [];
|
|
110
|
+
let quote = "";
|
|
111
|
+
for (; i < cleaned.length; i += 1) {
|
|
112
|
+
const c = cleaned[i];
|
|
113
|
+
if (quote) {
|
|
114
|
+
if (c === "\\") i += 1;
|
|
115
|
+
else if (c === quote) quote = "";
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
if (c === '"' || c === "'") {
|
|
119
|
+
quote = c;
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
if (c === "(") stack.push(")");
|
|
123
|
+
else if (c === "[") stack.push("]");
|
|
124
|
+
else if (c === "{") stack.push("}");
|
|
125
|
+
else if (c === ")" || c === "]" || c === "}") {
|
|
126
|
+
if (stack.pop() !== c) break;
|
|
127
|
+
if (!stack.length) {
|
|
128
|
+
i += 1;
|
|
129
|
+
break;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
out.push(cleaned.slice(start, i));
|
|
134
|
+
re.lastIndex = i;
|
|
135
|
+
}
|
|
136
|
+
return out;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function quoted(text: string): string[] {
|
|
140
|
+
const out: string[] = [];
|
|
141
|
+
const re = /"((?:[^"\\]|\\.)*)"/g;
|
|
142
|
+
let match: RegExpExecArray | null;
|
|
143
|
+
while ((match = re.exec(text))) out.push(match[1]);
|
|
144
|
+
return out;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Resolves a Bazel label to the repository-relative package directory.
|
|
149
|
+
* `//libs/shared:shared` → `libs/shared`, `:shared` → the current package, external repos → undefined.
|
|
150
|
+
*/
|
|
151
|
+
export function bazelLabelPath(label: string, fromPath: string): string | undefined {
|
|
152
|
+
if (label.startsWith("@")) return undefined; // external repository – not part of this map
|
|
153
|
+
if (label.startsWith(":")) return normalizePackagePath(fromPath);
|
|
154
|
+
if (!label.startsWith("//")) return undefined; // file names, generated targets, expansions
|
|
155
|
+
const body = label.slice(2);
|
|
156
|
+
const path = body.split(":")[0].replace(/\/+$/, "");
|
|
157
|
+
return path ? normalizePackagePath(path) : ".";
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** All dependency labels of a BUILD file. */
|
|
161
|
+
export function bazelDeps(text: string): string[] {
|
|
162
|
+
const labels = new Set<string>();
|
|
163
|
+
for (const value of attributeValues(text, "deps")) {
|
|
164
|
+
for (const label of quoted(value)) {
|
|
165
|
+
if (label.startsWith("//") || label.startsWith(":") || label.startsWith("@")) labels.add(label);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return [...labels];
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Turns parsed BUILD files into a tool graph. Kept pure so it can be tested without Bazel installed. */
|
|
172
|
+
export function parseBazelBuilds(builds: BuildFile[], packages: GraphPackage[]): ToolGraph | undefined {
|
|
173
|
+
const nameByPath = new Map(packages.map((pkg) => [normalizePackagePath(pkg.path), pkg.name]));
|
|
174
|
+
const edges = new Map<string, { from: string; to: string }>();
|
|
175
|
+
const seen = new Set<string>();
|
|
176
|
+
|
|
177
|
+
for (const build of builds) {
|
|
178
|
+
const fromPath = normalizePackagePath(build.path);
|
|
179
|
+
const from = nameByPath.get(fromPath);
|
|
180
|
+
if (!from) continue;
|
|
181
|
+
seen.add(from);
|
|
182
|
+
for (const label of bazelDeps(build.text)) {
|
|
183
|
+
const targetPath = bazelLabelPath(label, fromPath);
|
|
184
|
+
if (!targetPath) continue;
|
|
185
|
+
const to = nameByPath.get(targetPath);
|
|
186
|
+
if (!to || to === from) continue;
|
|
187
|
+
seen.add(to);
|
|
188
|
+
edges.set(`${from}>${to}`, { from, to });
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
if (!seen.size) return undefined;
|
|
192
|
+
|
|
193
|
+
return {
|
|
194
|
+
source: "bazel",
|
|
195
|
+
packages: [...seen].sort(),
|
|
196
|
+
edges: [...edges.values()].sort((a, b) => `${a.from}>${a.to}`.localeCompare(`${b.from}>${b.to}`)),
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Read every BUILD file and build the package graph. Returns undefined when there is none. */
|
|
201
|
+
export function readBazelGraph(root: string, packages: GraphPackage[]): ToolGraph | undefined {
|
|
202
|
+
const builds: BuildFile[] = [];
|
|
203
|
+
for (const entry of findBuildFiles(root)) {
|
|
204
|
+
const file = join(entry.path, entry.file);
|
|
205
|
+
try {
|
|
206
|
+
builds.push({ path: normalizePackagePath(relative(root, entry.path)), text: readFileSync(file, "utf8") });
|
|
207
|
+
} catch {
|
|
208
|
+
// unreadable BUILD file – skip it, the manifest graph still stands
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
return parseBazelBuilds(builds, packages);
|
|
212
|
+
}
|