@docsworth/cli 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +55 -0
- package/README.md +186 -2
- package/dist/cli.d.ts +11 -0
- package/dist/cli.js +409 -0
- package/dist/core/acl.d.ts +28 -0
- package/dist/core/acl.js +102 -0
- package/dist/core/audit-log.d.ts +133 -0
- package/dist/core/audit-log.js +290 -0
- package/dist/core/blocks.d.ts +35 -0
- package/dist/core/blocks.js +417 -0
- package/dist/core/codeowners.d.ts +62 -0
- package/dist/core/codeowners.js +133 -0
- package/dist/core/config.d.ts +96 -0
- package/dist/core/config.js +131 -0
- package/dist/core/coverage.d.ts +25 -0
- package/dist/core/coverage.js +41 -0
- package/dist/core/duration.d.ts +20 -0
- package/dist/core/duration.js +60 -0
- package/dist/core/errors.d.ts +11 -0
- package/dist/core/errors.js +13 -0
- package/dist/core/frontmatter.d.ts +17 -0
- package/dist/core/frontmatter.js +40 -0
- package/dist/core/gate.d.ts +17 -0
- package/dist/core/gate.js +25 -0
- package/dist/core/github.d.ts +152 -0
- package/dist/core/github.js +234 -0
- package/dist/core/host.d.ts +125 -0
- package/dist/core/host.js +22 -0
- package/dist/core/identity.d.ts +39 -0
- package/dist/core/identity.js +127 -0
- package/dist/core/init.d.ts +25 -0
- package/dist/core/init.js +86 -0
- package/dist/core/lint.d.ts +17 -0
- package/dist/core/lint.js +115 -0
- package/dist/core/mcp/audit-transport.d.ts +16 -0
- package/dist/core/mcp/audit-transport.js +238 -0
- package/dist/core/mcp/server.d.ts +19 -0
- package/dist/core/mcp/server.js +207 -0
- package/dist/core/mcp/text.d.ts +14 -0
- package/dist/core/mcp/text.js +43 -0
- package/dist/core/mirror-view.d.ts +68 -0
- package/dist/core/mirror-view.js +367 -0
- package/dist/core/new-page.d.ts +52 -0
- package/dist/core/new-page.js +49 -0
- package/dist/core/policy.d.ts +10 -0
- package/dist/core/policy.js +30 -0
- package/dist/core/polish.d.ts +42 -0
- package/dist/core/polish.js +1000 -0
- package/dist/core/references.d.ts +52 -0
- package/dist/core/references.js +1242 -0
- package/dist/core/repo.d.ts +42 -0
- package/dist/core/repo.js +116 -0
- package/dist/core/schema.d.ts +46 -0
- package/dist/core/schema.js +49 -0
- package/dist/core/search.d.ts +24 -0
- package/dist/core/search.js +98 -0
- package/dist/core/serve/auth.d.ts +23 -0
- package/dist/core/serve/auth.js +38 -0
- package/dist/core/serve/server.d.ts +22 -0
- package/dist/core/serve/server.js +138 -0
- package/dist/core/serve/sessions.d.ts +39 -0
- package/dist/core/serve/sessions.js +130 -0
- package/dist/core/serve/token.d.ts +10 -0
- package/dist/core/serve/token.js +96 -0
- package/dist/core/service.d.ts +375 -0
- package/dist/core/service.js +815 -0
- package/dist/core/sla.d.ts +16 -0
- package/dist/core/sla.js +20 -0
- package/dist/core/stale.d.ts +16 -0
- package/dist/core/stale.js +23 -0
- package/dist/core/tool-registry.d.ts +31 -0
- package/dist/core/tool-registry.js +77 -0
- package/dist/core/url.d.ts +4 -0
- package/dist/core/url.js +31 -0
- package/dist/core/version.d.ts +22 -0
- package/dist/core/version.js +32 -0
- package/dist/core/writes.d.ts +58 -0
- package/dist/core/writes.js +113 -0
- package/dist/web/assets/index-BglLMIyN.css +1 -0
- package/dist/web/assets/index-DugFPoRu.js +25 -0
- package/dist/web/index.html +14 -0
- package/docs/format-spec.md +311 -0
- package/package.json +75 -4
- package/plugin/.claude-plugin/plugin.json +8 -0
- package/plugin/.mcp.json +8 -0
- package/plugin/bin/docsworth-mcp.mjs +46 -0
- package/plugin/skills/draft-runbook/SKILL.md +75 -0
- package/plugin/skills/polish-page/SKILL.md +64 -0
- package/plugin/skills/update-after-incident/SKILL.md +59 -0
- package/plugin/skills/verify-page/SKILL.md +46 -0
- package/templates/github/knowledge.yml +51 -0
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { UserError } from "./errors.js";
|
|
3
|
+
export declare const CONFIG_PATH = ".knowledge/config.yaml";
|
|
4
|
+
/** The MCP tools a policy can name (kept here so config doesn't import the server). */
|
|
5
|
+
export declare const POLICY_TOOLS: readonly ["search", "read", "list", "stale", "propose_edit", "verify", "draft_page"];
|
|
6
|
+
declare const MirrorSourceSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
7
|
+
kind: z.ZodLiteral<"confluence">;
|
|
8
|
+
name: z.ZodDefault<z.ZodString>;
|
|
9
|
+
base_url: z.ZodString;
|
|
10
|
+
spaces: z.ZodArray<z.ZodObject<{
|
|
11
|
+
key: z.ZodString;
|
|
12
|
+
acl: z.ZodDefault<z.ZodString>;
|
|
13
|
+
}, z.core.$strip>>;
|
|
14
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
15
|
+
kind: z.ZodLiteral<"notion">;
|
|
16
|
+
name: z.ZodDefault<z.ZodString>;
|
|
17
|
+
root_page_ids: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
|
18
|
+
acl: z.ZodDefault<z.ZodString>;
|
|
19
|
+
}, z.core.$strip>], "kind">;
|
|
20
|
+
export declare const ConfigSchema: z.ZodObject<{
|
|
21
|
+
format: z.ZodDefault<z.ZodLiteral<1>>;
|
|
22
|
+
knowledge_root: z.ZodDefault<z.ZodString>;
|
|
23
|
+
github: z.ZodObject<{
|
|
24
|
+
org: z.ZodPipe<z.ZodString, z.ZodTransform<string, string>>;
|
|
25
|
+
repo: z.ZodOptional<z.ZodString>;
|
|
26
|
+
default_branch: z.ZodOptional<z.ZodString>;
|
|
27
|
+
api_url: z.ZodOptional<z.ZodString>;
|
|
28
|
+
}, z.core.$strip>;
|
|
29
|
+
sla: z.ZodDefault<z.ZodObject<{
|
|
30
|
+
due_soon_days: z.ZodDefault<z.ZodNumber>;
|
|
31
|
+
}, z.core.$strip>>;
|
|
32
|
+
usage_log: z.ZodDefault<z.ZodBoolean>;
|
|
33
|
+
audit_log: z.ZodDefault<z.ZodObject<{
|
|
34
|
+
enabled: z.ZodDefault<z.ZodBoolean>;
|
|
35
|
+
dir: z.ZodOptional<z.ZodString>;
|
|
36
|
+
include_queries: z.ZodDefault<z.ZodBoolean>;
|
|
37
|
+
required: z.ZodDefault<z.ZodBoolean>;
|
|
38
|
+
retention_days: z.ZodDefault<z.ZodNumber>;
|
|
39
|
+
}, z.core.$strict>>;
|
|
40
|
+
skills: z.ZodDefault<z.ZodObject<{
|
|
41
|
+
out_dir: z.ZodDefault<z.ZodString>;
|
|
42
|
+
}, z.core.$strip>>;
|
|
43
|
+
mirror: z.ZodDefault<z.ZodObject<{
|
|
44
|
+
out_dir: z.ZodDefault<z.ZodString>;
|
|
45
|
+
sources: z.ZodDefault<z.ZodArray<z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
46
|
+
kind: z.ZodLiteral<"confluence">;
|
|
47
|
+
name: z.ZodDefault<z.ZodString>;
|
|
48
|
+
base_url: z.ZodString;
|
|
49
|
+
spaces: z.ZodArray<z.ZodObject<{
|
|
50
|
+
key: z.ZodString;
|
|
51
|
+
acl: z.ZodDefault<z.ZodString>;
|
|
52
|
+
}, z.core.$strip>>;
|
|
53
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
54
|
+
kind: z.ZodLiteral<"notion">;
|
|
55
|
+
name: z.ZodDefault<z.ZodString>;
|
|
56
|
+
root_page_ids: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
|
57
|
+
acl: z.ZodDefault<z.ZodString>;
|
|
58
|
+
}, z.core.$strip>], "kind">>>;
|
|
59
|
+
serve: z.ZodDefault<z.ZodBoolean>;
|
|
60
|
+
}, z.core.$strip>>;
|
|
61
|
+
policy: z.ZodDefault<z.ZodObject<{
|
|
62
|
+
admins: z.ZodOptional<z.ZodString>;
|
|
63
|
+
tools: z.ZodDefault<z.ZodRecord<z.ZodEnum<{
|
|
64
|
+
draft_page: "draft_page";
|
|
65
|
+
list: "list";
|
|
66
|
+
propose_edit: "propose_edit";
|
|
67
|
+
read: "read";
|
|
68
|
+
search: "search";
|
|
69
|
+
stale: "stale";
|
|
70
|
+
verify: "verify";
|
|
71
|
+
}> & z.core.$partial, z.ZodObject<{
|
|
72
|
+
teams: z.ZodArray<z.ZodString>;
|
|
73
|
+
}, z.core.$strict>>>;
|
|
74
|
+
writes: z.ZodDefault<z.ZodEnum<{
|
|
75
|
+
disabled: "disabled";
|
|
76
|
+
enabled: "enabled";
|
|
77
|
+
}>>;
|
|
78
|
+
require_human_approval: z.ZodDefault<z.ZodBoolean>;
|
|
79
|
+
include_stale: z.ZodDefault<z.ZodEnum<{
|
|
80
|
+
allowed: "allowed";
|
|
81
|
+
denied: "denied";
|
|
82
|
+
}>>;
|
|
83
|
+
identity_cache_seconds: z.ZodDefault<z.ZodNumber>;
|
|
84
|
+
}, z.core.$strict>>;
|
|
85
|
+
digest: z.ZodDefault<z.ZodObject<{
|
|
86
|
+
slack_webhook_env: z.ZodDefault<z.ZodString>;
|
|
87
|
+
team_webhooks: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodString>>;
|
|
88
|
+
}, z.core.$strip>>;
|
|
89
|
+
audit_http: z.ZodOptional<z.ZodUnknown>;
|
|
90
|
+
}, z.core.$strict>;
|
|
91
|
+
export type Config = z.infer<typeof ConfigSchema>;
|
|
92
|
+
export type MirrorSourceConfig = z.infer<typeof MirrorSourceSchema>;
|
|
93
|
+
export declare class ConfigError extends UserError {
|
|
94
|
+
}
|
|
95
|
+
export declare function loadConfig(repoRoot: string): Config;
|
|
96
|
+
export {};
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { parse as parseYaml } from "yaml";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { UserError } from "./errors.js";
|
|
6
|
+
import { insecureUrl } from "./url.js";
|
|
7
|
+
export const CONFIG_PATH = ".knowledge/config.yaml";
|
|
8
|
+
/** The MCP tools a policy can name (kept here so config doesn't import the server). */
|
|
9
|
+
export const POLICY_TOOLS = ["search", "read", "list", "stale", "propose_edit", "verify", "draft_page"];
|
|
10
|
+
const TeamSlug = z.string().regex(/^[a-z0-9][a-z0-9_-]*$/, "a lowercase team slug like `platform`");
|
|
11
|
+
const MirrorSourceSchema = z.discriminatedUnion("kind", [
|
|
12
|
+
z.object({
|
|
13
|
+
kind: z.literal("confluence"),
|
|
14
|
+
name: z.string().default("confluence"),
|
|
15
|
+
base_url: z.string().url().superRefine((u, ctx) => {
|
|
16
|
+
const why = insecureUrl(u);
|
|
17
|
+
if (why)
|
|
18
|
+
ctx.addIssue({ code: "custom", message: `base_url ${why}` });
|
|
19
|
+
}),
|
|
20
|
+
spaces: z.array(z.object({ key: z.string().min(1), acl: z.string().default("internal") })).min(1),
|
|
21
|
+
}),
|
|
22
|
+
z.object({
|
|
23
|
+
kind: z.literal("notion"),
|
|
24
|
+
name: z.string().default("notion"),
|
|
25
|
+
root_page_ids: z.array(z.string()).default([]),
|
|
26
|
+
acl: z.string().default("internal"),
|
|
27
|
+
}),
|
|
28
|
+
]);
|
|
29
|
+
export const ConfigSchema = z.object({
|
|
30
|
+
format: z.literal(1).default(1),
|
|
31
|
+
knowledge_root: z.string().default("knowledge"),
|
|
32
|
+
github: z.object({
|
|
33
|
+
/** Team owners resolve to @<org>/<team>. Required even for local-only repos. */
|
|
34
|
+
org: z.string().min(1).transform((s) => s.toLowerCase()),
|
|
35
|
+
/** owner/name. When set, GitHub is the ACL authority and a token is required. */
|
|
36
|
+
repo: z
|
|
37
|
+
.string()
|
|
38
|
+
.regex(/^[\w.-]+\/[\w.-]+$/, "owner/name")
|
|
39
|
+
.optional(),
|
|
40
|
+
default_branch: z.string().optional(),
|
|
41
|
+
/** REST API base for GitHub Enterprise Server, e.g. https://ghe.example.com/api/v3. */
|
|
42
|
+
api_url: z
|
|
43
|
+
.string()
|
|
44
|
+
.url()
|
|
45
|
+
.superRefine((u, ctx) => {
|
|
46
|
+
const why = insecureUrl(u);
|
|
47
|
+
if (why)
|
|
48
|
+
ctx.addIssue({ code: "custom", message: `github.api_url ${why}` });
|
|
49
|
+
})
|
|
50
|
+
.optional(),
|
|
51
|
+
}),
|
|
52
|
+
sla: z.object({ due_soon_days: z.number().int().min(0).default(14) }).default({ due_soon_days: 14 }),
|
|
53
|
+
/** Append page-id-only read events to .knowledge/usage.jsonl (feeds the digest). */
|
|
54
|
+
usage_log: z.boolean().default(false),
|
|
55
|
+
/** The attributed audit log (src/core/audit-log.ts). Off unless enabled. */
|
|
56
|
+
audit_log: z
|
|
57
|
+
.object({
|
|
58
|
+
enabled: z.boolean().default(false),
|
|
59
|
+
/** Default: a state directory outside the checkout. Inside it, git must ignore it. */
|
|
60
|
+
dir: z.string().min(1).optional(),
|
|
61
|
+
/** Record search query text. Off: queries can carry sensitive text. */
|
|
62
|
+
include_queries: z.boolean().default(false),
|
|
63
|
+
/** Fail a tool call (closed) when its event can't be written. */
|
|
64
|
+
required: z.boolean().default(false),
|
|
65
|
+
retention_days: z.number().int().min(1).default(365),
|
|
66
|
+
})
|
|
67
|
+
.strict() // `requred: true` must fail, not quietly make the log optional
|
|
68
|
+
// "Required" with nothing enabled would quietly mean no audit log at all.
|
|
69
|
+
.refine((a) => !a.required || a.enabled, { message: "audit_log.required needs audit_log.enabled: true (otherwise nothing is audited)" })
|
|
70
|
+
.default({ enabled: false, include_queries: false, required: false, retention_days: 365 }),
|
|
71
|
+
skills: z.object({ out_dir: z.string().default("skills") }).default({ out_dir: "skills" }),
|
|
72
|
+
mirror: z
|
|
73
|
+
.object({
|
|
74
|
+
out_dir: z.string().default(".knowledge/mirror"),
|
|
75
|
+
sources: z.array(MirrorSourceSchema).default([]),
|
|
76
|
+
/** Serve mirrored pages to agents as non-citable context (v0.2 L2). Off by default. */
|
|
77
|
+
serve: z.boolean().default(false),
|
|
78
|
+
})
|
|
79
|
+
.default({ out_dir: ".knowledge/mirror", sources: [], serve: false }),
|
|
80
|
+
/** Org policy and roles (src/core/policy.ts). Every default keeps v0.1 behaviour. */
|
|
81
|
+
policy: z
|
|
82
|
+
.object({
|
|
83
|
+
/** The team that must own .knowledge/config.yaml and CODEOWNERS in CODEOWNERS (lint). */
|
|
84
|
+
admins: TeamSlug.optional(),
|
|
85
|
+
/** Per-tool allowlists. An empty list disables the tool for everyone. */
|
|
86
|
+
tools: z.partialRecord(z.enum(POLICY_TOOLS), z.object({ teams: z.array(TeamSlug) }).strict()).default({}),
|
|
87
|
+
/** `disabled`: propose_edit and verify are not offered at all. */
|
|
88
|
+
writes: z.enum(["enabled", "disabled"]).default("enabled"),
|
|
89
|
+
/** Mark write tools so Claude Code asks a human on every call (defence in depth). */
|
|
90
|
+
require_human_approval: z.boolean().default(false),
|
|
91
|
+
/** `denied`: search never returns gated pages inline, even with include_stale. */
|
|
92
|
+
include_stale: z.enum(["allowed", "denied"]).default("allowed"),
|
|
93
|
+
/** How long team membership is cached for reads: the deprovisioning window. */
|
|
94
|
+
identity_cache_seconds: z.number().int().min(30).max(3600).default(300),
|
|
95
|
+
})
|
|
96
|
+
// A typo here must be an error, not a silently ignored key: `write: disabled`
|
|
97
|
+
// would otherwise leave writes on.
|
|
98
|
+
.strict()
|
|
99
|
+
.default({ tools: {}, writes: "enabled", require_human_approval: false, include_stale: "allowed", identity_cache_seconds: 300 }),
|
|
100
|
+
digest: z
|
|
101
|
+
.object({
|
|
102
|
+
/** Env var holding the shared Slack incoming-webhook URL. */
|
|
103
|
+
slack_webhook_env: z.string().default("SLACK_WEBHOOK_URL"),
|
|
104
|
+
/**
|
|
105
|
+
* Per-team webhook env vars. Each MUST point at that team's own private channel:
|
|
106
|
+
* restricted page titles are posted there by name. Shared channels go through
|
|
107
|
+
* slack_webhook_env, where restricted pages are only counted.
|
|
108
|
+
*/
|
|
109
|
+
team_webhooks: z.record(z.string(), z.string()).default({}),
|
|
110
|
+
})
|
|
111
|
+
.default({ slack_webhook_env: "SLACK_WEBHOOK_URL", team_webhooks: {} }),
|
|
112
|
+
/** Pro: the audit collector. Docsworth pro parses it; core only reserves the key. */
|
|
113
|
+
audit_http: z.unknown().optional(),
|
|
114
|
+
})
|
|
115
|
+
// Every top-level key is known, so a misspelled block (`polciy:`, `audit_lgo:`) is an
|
|
116
|
+
// error rather than a silently dropped one that would leave writes on or auditing off.
|
|
117
|
+
.strict();
|
|
118
|
+
export class ConfigError extends UserError {
|
|
119
|
+
}
|
|
120
|
+
export function loadConfig(repoRoot) {
|
|
121
|
+
const abs = join(repoRoot, CONFIG_PATH);
|
|
122
|
+
if (!existsSync(abs))
|
|
123
|
+
throw new ConfigError(`no ${CONFIG_PATH} in ${repoRoot} (run \`docsworth init\`)`);
|
|
124
|
+
const raw = parseYaml(readFileSync(abs, "utf8"), { schema: "core" });
|
|
125
|
+
const result = ConfigSchema.safeParse(raw ?? {});
|
|
126
|
+
if (!result.success) {
|
|
127
|
+
const detail = result.error.issues.map((i) => `${i.path.join(".") || "(root)"}: ${i.message}`).join("; ");
|
|
128
|
+
throw new ConfigError(`invalid ${CONFIG_PATH}: ${detail}`);
|
|
129
|
+
}
|
|
130
|
+
return result.data;
|
|
131
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { Repo } from "./repo.js";
|
|
2
|
+
export interface CoverageRow {
|
|
3
|
+
team: string;
|
|
4
|
+
pages: number;
|
|
5
|
+
owned: number;
|
|
6
|
+
owned_pct: number;
|
|
7
|
+
/** Pages subject to review: everything except deprecated pages. */
|
|
8
|
+
reviewable: number;
|
|
9
|
+
within_sla: number;
|
|
10
|
+
within_sla_pct: number;
|
|
11
|
+
}
|
|
12
|
+
export interface Coverage {
|
|
13
|
+
as_of: string;
|
|
14
|
+
total: CoverageRow;
|
|
15
|
+
teams: CoverageRow[];
|
|
16
|
+
}
|
|
17
|
+
/** Team bucket for pages whose frontmatter is too broken to name an owner. */
|
|
18
|
+
export declare const UNASSIGNED = "(unassigned)";
|
|
19
|
+
/**
|
|
20
|
+
* Ownership coverage (§4.3): % of pages team-owned and % verified within SLA, per team.
|
|
21
|
+
* Invalid pages count as neither owned nor verified, so a broken page lowers coverage
|
|
22
|
+
* instead of disappearing from it.
|
|
23
|
+
*/
|
|
24
|
+
export declare function computeCoverage(repo: Repo, now: Date): Coverage;
|
|
25
|
+
export declare function formatCoverage(c: Coverage): string;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { pageStatus } from "./gate.js";
|
|
2
|
+
import { withinSla } from "./sla.js";
|
|
3
|
+
/** Team bucket for pages whose frontmatter is too broken to name an owner. */
|
|
4
|
+
export const UNASSIGNED = "(unassigned)";
|
|
5
|
+
const pct = (n, d) => (d === 0 ? 100 : Math.round((n / d) * 1000) / 10);
|
|
6
|
+
function row(team, items) {
|
|
7
|
+
const owned = items.filter((i) => i.owned).length;
|
|
8
|
+
const reviewable = items.filter((i) => i.reviewable).length;
|
|
9
|
+
const within = items.filter((i) => i.reviewable && i.within).length;
|
|
10
|
+
return {
|
|
11
|
+
team,
|
|
12
|
+
pages: items.length,
|
|
13
|
+
owned,
|
|
14
|
+
owned_pct: pct(owned, items.length),
|
|
15
|
+
reviewable,
|
|
16
|
+
within_sla: within,
|
|
17
|
+
within_sla_pct: pct(within, reviewable),
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Ownership coverage (§4.3): % of pages team-owned and % verified within SLA, per team.
|
|
22
|
+
* Invalid pages count as neither owned nor verified, so a broken page lowers coverage
|
|
23
|
+
* instead of disappearing from it.
|
|
24
|
+
*/
|
|
25
|
+
export function computeCoverage(repo, now) {
|
|
26
|
+
const byTeam = new Map();
|
|
27
|
+
const add = (team, item) => byTeam.set(team, [...(byTeam.get(team) ?? []), item]);
|
|
28
|
+
for (const page of repo.pages) {
|
|
29
|
+
const s = pageStatus(page, repo, now);
|
|
30
|
+
add(page.owner_team, { owned: s.owned, reviewable: page.status !== "deprecated", within: withinSla(s.sla.state) });
|
|
31
|
+
}
|
|
32
|
+
for (const _ of repo.invalid)
|
|
33
|
+
add(UNASSIGNED, { owned: false, reviewable: true, within: false });
|
|
34
|
+
const teams = [...byTeam.entries()].map(([team, items]) => row(team, items)).sort((a, b) => a.team.localeCompare(b.team));
|
|
35
|
+
return { as_of: now.toISOString().slice(0, 10), total: row("total", [...byTeam.values()].flat()), teams };
|
|
36
|
+
}
|
|
37
|
+
export function formatCoverage(c) {
|
|
38
|
+
const header = "team pages owned within SLA";
|
|
39
|
+
const fmt = (r) => `${r.team.padEnd(15)} ${String(r.pages).padStart(5)} ${`${r.owned}/${r.pages}`.padStart(6)} ${`${r.owned_pct}%`.padStart(6)} ${`${r.within_sla}/${r.reviewable}`.padStart(6)} ${`${r.within_sla_pct}%`.padStart(6)}`;
|
|
40
|
+
return [`Ownership coverage as of ${c.as_of}`, header, ...c.teams.map(fmt), fmt(c.total)].join("\n");
|
|
41
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export type DurationUnit = "d" | "w" | "m" | "y";
|
|
2
|
+
export interface Duration {
|
|
3
|
+
n: number;
|
|
4
|
+
unit: DurationUnit;
|
|
5
|
+
}
|
|
6
|
+
export declare const DURATION_RE: RegExp;
|
|
7
|
+
export declare function parseDuration(s: unknown): Duration | null;
|
|
8
|
+
/** Parse `YYYY-MM-DD` to UTC midnight; rejects impossible dates like 2026-02-30. */
|
|
9
|
+
export declare function parseDate(s: unknown): Date | null;
|
|
10
|
+
export declare function formatDate(d: Date): string;
|
|
11
|
+
/** The UTC calendar day containing `now`. */
|
|
12
|
+
export declare function utcToday(now: Date): Date;
|
|
13
|
+
/**
|
|
14
|
+
* Add a duration. Days and weeks are fixed; months and years are calendar units
|
|
15
|
+
* clamped to the end of the target month (Jan 31 + 1m = Feb 28/29).
|
|
16
|
+
*/
|
|
17
|
+
export declare function addDuration(date: Date, dur: Duration): Date;
|
|
18
|
+
/** Go back by a duration, clamping the same way (Mar 31 − 1m = Feb 28/29). */
|
|
19
|
+
export declare function subtractDuration(date: Date, dur: Duration): Date;
|
|
20
|
+
export declare function daysBetween(from: Date, to: Date): number;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// Date-only calendar arithmetic for review SLAs.
|
|
2
|
+
//
|
|
3
|
+
// All dates are UTC midnight `Date`s produced by `parseDate`, so a page verified on
|
|
4
|
+
// 2026-10-15 is due on the same calendar day for every reader regardless of timezone.
|
|
5
|
+
export const DURATION_RE = /^([1-9]\d{0,3})([dwmy])$/;
|
|
6
|
+
const DATE_RE = /^(\d{4})-(\d{2})-(\d{2})$/;
|
|
7
|
+
const DAY_MS = 86_400_000;
|
|
8
|
+
export function parseDuration(s) {
|
|
9
|
+
if (typeof s !== "string")
|
|
10
|
+
return null;
|
|
11
|
+
const m = DURATION_RE.exec(s.trim());
|
|
12
|
+
return m ? { n: Number(m[1]), unit: m[2] } : null;
|
|
13
|
+
}
|
|
14
|
+
/** Parse `YYYY-MM-DD` to UTC midnight; rejects impossible dates like 2026-02-30. */
|
|
15
|
+
export function parseDate(s) {
|
|
16
|
+
if (typeof s !== "string")
|
|
17
|
+
return null;
|
|
18
|
+
const m = DATE_RE.exec(s.trim());
|
|
19
|
+
if (!m)
|
|
20
|
+
return null;
|
|
21
|
+
const [y, mo, d] = [Number(m[1]), Number(m[2]), Number(m[3])];
|
|
22
|
+
const date = new Date(Date.UTC(y, mo - 1, d));
|
|
23
|
+
const roundTrips = date.getUTCFullYear() === y && date.getUTCMonth() === mo - 1 && date.getUTCDate() === d;
|
|
24
|
+
return roundTrips ? date : null;
|
|
25
|
+
}
|
|
26
|
+
export function formatDate(d) {
|
|
27
|
+
return d.toISOString().slice(0, 10);
|
|
28
|
+
}
|
|
29
|
+
/** The UTC calendar day containing `now`. */
|
|
30
|
+
export function utcToday(now) {
|
|
31
|
+
return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Add a duration. Days and weeks are fixed; months and years are calendar units
|
|
35
|
+
* clamped to the end of the target month (Jan 31 + 1m = Feb 28/29).
|
|
36
|
+
*/
|
|
37
|
+
export function addDuration(date, dur) {
|
|
38
|
+
if (dur.unit === "d" || dur.unit === "w") {
|
|
39
|
+
return new Date(date.getTime() + dur.n * (dur.unit === "w" ? 7 : 1) * DAY_MS);
|
|
40
|
+
}
|
|
41
|
+
const months = dur.unit === "y" ? dur.n * 12 : dur.n;
|
|
42
|
+
const y = date.getUTCFullYear();
|
|
43
|
+
const targetMonth = date.getUTCMonth() + months;
|
|
44
|
+
const lastDayOfTarget = new Date(Date.UTC(y, targetMonth + 1, 0)).getUTCDate();
|
|
45
|
+
return new Date(Date.UTC(y, targetMonth, Math.min(date.getUTCDate(), lastDayOfTarget)));
|
|
46
|
+
}
|
|
47
|
+
/** Go back by a duration, clamping the same way (Mar 31 − 1m = Feb 28/29). */
|
|
48
|
+
export function subtractDuration(date, dur) {
|
|
49
|
+
if (dur.unit === "d" || dur.unit === "w") {
|
|
50
|
+
return new Date(date.getTime() - dur.n * (dur.unit === "w" ? 7 : 1) * DAY_MS);
|
|
51
|
+
}
|
|
52
|
+
const months = dur.unit === "y" ? dur.n * 12 : dur.n;
|
|
53
|
+
const y = date.getUTCFullYear();
|
|
54
|
+
const targetMonth = date.getUTCMonth() - months;
|
|
55
|
+
const lastDayOfTarget = new Date(Date.UTC(y, targetMonth + 1, 0)).getUTCDate();
|
|
56
|
+
return new Date(Date.UTC(y, targetMonth, Math.min(date.getUTCDate(), lastDayOfTarget)));
|
|
57
|
+
}
|
|
58
|
+
export function daysBetween(from, to) {
|
|
59
|
+
return Math.round((to.getTime() - from.getTime()) / DAY_MS);
|
|
60
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An error the person running the CLI can act on: it prints as its message alone and exits
|
|
3
|
+
* 2, rather than as a stack. It's recognized by a brand, not by its class, so the CLI tells
|
|
4
|
+
* Docsworth pro's errors apart without importing pro: pro is another package, and its errors
|
|
5
|
+
* may extend another copy of this class.
|
|
6
|
+
*/
|
|
7
|
+
export declare class UserError extends Error {
|
|
8
|
+
readonly userError: true;
|
|
9
|
+
}
|
|
10
|
+
/** Whether `err` is a UserError, from this copy of core or any other. */
|
|
11
|
+
export declare function isUserError(err: unknown): err is Error;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An error the person running the CLI can act on: it prints as its message alone and exits
|
|
3
|
+
* 2, rather than as a stack. It's recognized by a brand, not by its class, so the CLI tells
|
|
4
|
+
* Docsworth pro's errors apart without importing pro: pro is another package, and its errors
|
|
5
|
+
* may extend another copy of this class.
|
|
6
|
+
*/
|
|
7
|
+
export class UserError extends Error {
|
|
8
|
+
userError = true;
|
|
9
|
+
}
|
|
10
|
+
/** Whether `err` is a UserError, from this copy of core or any other. */
|
|
11
|
+
export function isUserError(err) {
|
|
12
|
+
return err instanceof Error && err.userError === true;
|
|
13
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export interface SplitDoc {
|
|
2
|
+
/** Parsed frontmatter mapping, or null when absent/unparseable (see `error`). */
|
|
3
|
+
data: Record<string, unknown> | null;
|
|
4
|
+
body: string;
|
|
5
|
+
error?: string;
|
|
6
|
+
}
|
|
7
|
+
export declare const FRONTMATTER_MAX: number;
|
|
8
|
+
/**
|
|
9
|
+
* Split `---` frontmatter from a markdown file and parse it as YAML.
|
|
10
|
+
*
|
|
11
|
+
* The YAML "core" schema is used on purpose: under YAML 1.1 rules `2026-10-15` would
|
|
12
|
+
* become a Date and `review_every: 90d` could be coerced oddly. Here every scalar that
|
|
13
|
+
* isn't a plain number/bool/null stays a string, so dates round-trip exactly.
|
|
14
|
+
*/
|
|
15
|
+
export declare function splitFrontmatter(text: string): SplitDoc;
|
|
16
|
+
/** Serialize frontmatter + body back to a markdown file (used by writes and promote). */
|
|
17
|
+
export declare function joinFrontmatter(yaml: string, body: string): string;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { parse as parseYaml } from "yaml";
|
|
2
|
+
const OPEN = /^---\r?\n/;
|
|
3
|
+
export const FRONTMATTER_MAX = 16 * 1024;
|
|
4
|
+
const CLOSE = /\r?\n---[ \t]*(\r?\n|$)/;
|
|
5
|
+
/**
|
|
6
|
+
* Split `---` frontmatter from a markdown file and parse it as YAML.
|
|
7
|
+
*
|
|
8
|
+
* The YAML "core" schema is used on purpose: under YAML 1.1 rules `2026-10-15` would
|
|
9
|
+
* become a Date and `review_every: 90d` could be coerced oddly. Here every scalar that
|
|
10
|
+
* isn't a plain number/bool/null stays a string, so dates round-trip exactly.
|
|
11
|
+
*/
|
|
12
|
+
export function splitFrontmatter(text) {
|
|
13
|
+
const open = OPEN.exec(text);
|
|
14
|
+
if (!open)
|
|
15
|
+
return { data: null, body: text, error: "missing frontmatter block" };
|
|
16
|
+
const rest = text.slice(open[0].length);
|
|
17
|
+
const close = CLOSE.exec(rest);
|
|
18
|
+
if (!close)
|
|
19
|
+
return { data: null, body: text, error: "unterminated frontmatter block" };
|
|
20
|
+
const yamlSrc = rest.slice(0, close.index);
|
|
21
|
+
const body = rest.slice(close.index + close[0].length);
|
|
22
|
+
// A page's frontmatter is a few hundred bytes; YAML parsing slows past linear on
|
|
23
|
+
// hostile input (64 KB of `}&`: 650 ms, paid on every reload).
|
|
24
|
+
if (Buffer.byteLength(yamlSrc, "utf8") > FRONTMATTER_MAX)
|
|
25
|
+
return { data: null, body, error: `frontmatter is over ${FRONTMATTER_MAX / 1024} KB` };
|
|
26
|
+
try {
|
|
27
|
+
const data = parseYaml(yamlSrc, { schema: "core" });
|
|
28
|
+
if (data === null || typeof data !== "object" || Array.isArray(data)) {
|
|
29
|
+
return { data: null, body, error: "frontmatter is not a mapping" };
|
|
30
|
+
}
|
|
31
|
+
return { data: data, body };
|
|
32
|
+
}
|
|
33
|
+
catch (err) {
|
|
34
|
+
return { data: null, body, error: `invalid YAML: ${err.message.split("\n")[0]}` };
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
/** Serialize frontmatter + body back to a markdown file (used by writes and promote). */
|
|
38
|
+
export function joinFrontmatter(yaml, body) {
|
|
39
|
+
return `---\n${yaml.trimEnd()}\n---\n${body.startsWith("\n") ? body : `\n${body}`}`;
|
|
40
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { Page, Repo } from "./repo.js";
|
|
2
|
+
import { type Sla } from "./sla.js";
|
|
3
|
+
export interface PageStatus {
|
|
4
|
+
sla: Sla;
|
|
5
|
+
/** CODEOWNERS resolves to exactly one team of the org, and it equals owner_team. */
|
|
6
|
+
owned: boolean;
|
|
7
|
+
ownership_problem?: string;
|
|
8
|
+
/** May an agent cite this page? canonical + within SLA + owned. Not configurable. */
|
|
9
|
+
citable: boolean;
|
|
10
|
+
/** Why it is not citable, phrased for an agent's answer ("excluded: …"). */
|
|
11
|
+
gate_reason?: string;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* The citation gate. Hard-coded on purpose (spec R11): no config may widen it, because
|
|
15
|
+
* a gate a team can switch off is the "rebuild Confluence with vectors" failure mode.
|
|
16
|
+
*/
|
|
17
|
+
export declare function pageStatus(page: Page, repo: Repo, now: Date): PageStatus;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { describeOwnershipProblem, resolveTeamOwner } from "./codeowners.js";
|
|
2
|
+
import { computeSla, withinSla } from "./sla.js";
|
|
3
|
+
/**
|
|
4
|
+
* The citation gate. Hard-coded on purpose (spec R11): no config may widen it, because
|
|
5
|
+
* a gate a team can switch off is the "rebuild Confluence with vectors" failure mode.
|
|
6
|
+
*/
|
|
7
|
+
export function pageStatus(page, repo, now) {
|
|
8
|
+
const sla = computeSla(page.last_verified, page.review_every, now, repo.config.sla.due_soon_days);
|
|
9
|
+
const own = resolveTeamOwner(repo.codeowners, page.path, repo.config.github.org);
|
|
10
|
+
const owned = own.ok && own.team === page.owner_team.toLowerCase();
|
|
11
|
+
const ownership_problem = own.ok
|
|
12
|
+
? owned
|
|
13
|
+
? undefined
|
|
14
|
+
: `owner_team is \`${page.owner_team}\` but CODEOWNERS assigns @${repo.config.github.org}/${own.team}`
|
|
15
|
+
: describeOwnershipProblem(own.problem, repo.config.github.org);
|
|
16
|
+
let gate_reason;
|
|
17
|
+
if (page.status !== "canonical")
|
|
18
|
+
gate_reason = `status is ${page.status}`;
|
|
19
|
+
else if (!withinSla(sla.state))
|
|
20
|
+
gate_reason =
|
|
21
|
+
sla.state === "overdue" ? `past review date (due ${sla.due}), owned by ${page.owner_team}` : "never verified";
|
|
22
|
+
else if (!owned)
|
|
23
|
+
gate_reason = `ownership unresolved: ${ownership_problem}`;
|
|
24
|
+
return { sla, owned, ownership_problem, citable: gate_reason === undefined, gate_reason };
|
|
25
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
export type FetchLike = (url: string, init?: RequestInit) => Promise<Response>;
|
|
2
|
+
export declare class GitHubError extends Error {
|
|
3
|
+
/** The HTTP status; 0 when GitHub didn't answer (a network error or a timeout). */
|
|
4
|
+
readonly status: number;
|
|
5
|
+
readonly method: string;
|
|
6
|
+
readonly path: string;
|
|
7
|
+
/** Response headers, e.g. `x-github-sso` on a 403 from an SSO-enforcing org. */
|
|
8
|
+
readonly headers: Headers | null;
|
|
9
|
+
/** GitHub's rate limits: a 429, or a 403 that says so (primary or secondary limit). */
|
|
10
|
+
readonly rateLimited: boolean;
|
|
11
|
+
/** Seconds GitHub asked to wait (`retry-after`, or until `x-ratelimit-reset`), if it said. */
|
|
12
|
+
readonly retryAfter: number | null;
|
|
13
|
+
constructor(
|
|
14
|
+
/** The HTTP status; 0 when GitHub didn't answer (a network error or a timeout). */
|
|
15
|
+
status: number, method: string, path: string, message: string,
|
|
16
|
+
/** Response headers, e.g. `x-github-sso` on a 403 from an SSO-enforcing org. */
|
|
17
|
+
headers?: Headers | null, now?: number);
|
|
18
|
+
}
|
|
19
|
+
export interface RepoInfo {
|
|
20
|
+
full_name: string;
|
|
21
|
+
default_branch: string;
|
|
22
|
+
private: boolean;
|
|
23
|
+
permissions?: {
|
|
24
|
+
admin?: boolean;
|
|
25
|
+
push?: boolean;
|
|
26
|
+
pull?: boolean;
|
|
27
|
+
};
|
|
28
|
+
owner?: {
|
|
29
|
+
login: string;
|
|
30
|
+
id: number;
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
export interface TeamInfo {
|
|
34
|
+
slug: string;
|
|
35
|
+
organization: {
|
|
36
|
+
login: string;
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
export interface ContentFile {
|
|
40
|
+
sha: string;
|
|
41
|
+
content: string;
|
|
42
|
+
encoding: string;
|
|
43
|
+
path: string;
|
|
44
|
+
}
|
|
45
|
+
export interface PullInfo {
|
|
46
|
+
number: number;
|
|
47
|
+
html_url: string;
|
|
48
|
+
head: {
|
|
49
|
+
ref: string;
|
|
50
|
+
};
|
|
51
|
+
base: {
|
|
52
|
+
ref: string;
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
export declare class GitHubClient {
|
|
56
|
+
private readonly token;
|
|
57
|
+
private readonly fetch;
|
|
58
|
+
private readonly baseUrl;
|
|
59
|
+
private readonly sleep;
|
|
60
|
+
private readonly now;
|
|
61
|
+
private readonly timeoutMs;
|
|
62
|
+
constructor(token: string, opts?: {
|
|
63
|
+
fetch?: FetchLike;
|
|
64
|
+
baseUrl?: string;
|
|
65
|
+
sleep?: (ms: number) => Promise<void>;
|
|
66
|
+
now?: () => number;
|
|
67
|
+
timeoutMs?: number;
|
|
68
|
+
});
|
|
69
|
+
request<T>(method: string, path: string, body?: unknown, accept?: string): Promise<{
|
|
70
|
+
data: T;
|
|
71
|
+
headers: Headers;
|
|
72
|
+
}>;
|
|
73
|
+
private get;
|
|
74
|
+
/** Follow `Link: rel="next"` pagination; `onHeaders` sees every page's headers. */
|
|
75
|
+
paginate<T>(path: string, onHeaders?: (h: Headers) => void): Promise<T[]>;
|
|
76
|
+
getUser(): Promise<{
|
|
77
|
+
login: string;
|
|
78
|
+
}>;
|
|
79
|
+
getRepo(repo: string): Promise<RepoInfo>;
|
|
80
|
+
/**
|
|
81
|
+
* The caller's teams, and the ids of orgs GitHub left out because the token isn't
|
|
82
|
+
* authorized for their SAML SSO (`x-github-sso: partial-results; organizations=…`).
|
|
83
|
+
*/
|
|
84
|
+
listMyTeams(): Promise<{
|
|
85
|
+
teams: TeamInfo[];
|
|
86
|
+
ssoWithheldOrgIds: string[];
|
|
87
|
+
}>;
|
|
88
|
+
/** Logins of a team's members (needs read:org, or members:read on a fine-grained token). */
|
|
89
|
+
listTeamMembers(org: string, team: string): Promise<string[]>;
|
|
90
|
+
/**
|
|
91
|
+
* Does a team have write access to a repo? GitHub ignores a CODEOWNERS team without it.
|
|
92
|
+
* true/false when GitHub shows the permission; null on a 404, which GitHub gives both
|
|
93
|
+
* for "no access" and for a team this caller can't see, so it proves nothing.
|
|
94
|
+
*/
|
|
95
|
+
teamCanPush(org: string, team: string, repo: string): Promise<boolean | null>;
|
|
96
|
+
/** A user's permission on a repo: admin, maintain, write, triage, read or none. */
|
|
97
|
+
getUserPermission(repo: string, login: string): Promise<string>;
|
|
98
|
+
/** Ruleset rules that apply to a branch (readable with read access). */
|
|
99
|
+
getBranchRules(repo: string, branch: string): Promise<{
|
|
100
|
+
type: string;
|
|
101
|
+
parameters?: Record<string, unknown>;
|
|
102
|
+
ruleset_id?: number;
|
|
103
|
+
}[]>;
|
|
104
|
+
/** A ruleset, with `bypass_actors` when this token may see them (admins). */
|
|
105
|
+
getRuleset(repo: string, id: number): Promise<{
|
|
106
|
+
bypass_actors?: {
|
|
107
|
+
actor_type: string;
|
|
108
|
+
bypass_mode?: string;
|
|
109
|
+
}[];
|
|
110
|
+
}>;
|
|
111
|
+
/** Classic branch protection (GitHub answers 403/404 to callers without admin). */
|
|
112
|
+
getBranchProtection(repo: string, branch: string): Promise<{
|
|
113
|
+
required_pull_request_reviews?: {
|
|
114
|
+
require_code_owner_reviews?: boolean;
|
|
115
|
+
bypass_pull_request_allowances?: {
|
|
116
|
+
users?: unknown[];
|
|
117
|
+
teams?: unknown[];
|
|
118
|
+
apps?: unknown[];
|
|
119
|
+
};
|
|
120
|
+
};
|
|
121
|
+
enforce_admins?: {
|
|
122
|
+
enabled?: boolean;
|
|
123
|
+
};
|
|
124
|
+
}>;
|
|
125
|
+
getFile(repo: string, path: string, ref: string): Promise<ContentFile>;
|
|
126
|
+
getBranchSha(repo: string, branch: string): Promise<string>;
|
|
127
|
+
createBranch(repo: string, branch: string, sha: string): Promise<void>;
|
|
128
|
+
deleteBranch(repo: string, branch: string): Promise<void>;
|
|
129
|
+
/** Always pass `branch`: omitting it makes GitHub write to the default branch. */
|
|
130
|
+
putFile(repo: string, path: string, args: {
|
|
131
|
+
message: string;
|
|
132
|
+
content: string;
|
|
133
|
+
sha: string | undefined;
|
|
134
|
+
branch: string;
|
|
135
|
+
}): Promise<void>;
|
|
136
|
+
/** An open PR from `branch` into `base` in the same repo, if one exists. */
|
|
137
|
+
findPull(repo: string, branch: string, base: string): Promise<PullInfo | null>;
|
|
138
|
+
createPull(repo: string, args: {
|
|
139
|
+
title: string;
|
|
140
|
+
head: string;
|
|
141
|
+
base: string;
|
|
142
|
+
body: string;
|
|
143
|
+
draft?: boolean;
|
|
144
|
+
}): Promise<PullInfo>;
|
|
145
|
+
}
|
|
146
|
+
/** `required; url=https://…` or `partial-results; organizations=1,2`. */
|
|
147
|
+
export declare function parseSsoHeader(value: string | null): {
|
|
148
|
+
required: boolean;
|
|
149
|
+
url: string | null;
|
|
150
|
+
organizations: string[];
|
|
151
|
+
};
|
|
152
|
+
export declare function decodeContent(file: ContentFile): string;
|