-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathrelease-package-contract.ts
More file actions
270 lines (250 loc) · 9.18 KB
/
release-package-contract.ts
File metadata and controls
270 lines (250 loc) · 9.18 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
import fs from "node:fs";
import path from "node:path";
import { z } from "zod";
import type { Diagnostic } from "./diagnostics.js";
import { readArtifactSafe } from "./artifact-reader.js";
import { toPosix } from "./repo.js";
const DocStubFrontmatterSchema = z.object({
title: z.string().optional(),
folder: z.string().optional(),
description: z.string().optional(),
entry_point: z.unknown().optional(),
});
/**
* Intake folders enumerated in ADR-0021 §Decision.3 and the package contract.
*
* Each folder must ship either absent from the released archive or contain only
* a top-level `README.md`. Order matches the enumeration in
* `docs/release-package-contents.md`.
*/
export const INTAKE_FOLDERS: readonly string[] = [
"inputs",
"specs",
"discovery",
"projects",
"portfolio",
"roadmaps",
"quality",
"scaffolding",
"stock-taking",
"sales",
"issues",
];
/**
* Filename pattern for numbered ADR records (`0001-*.md` ... `9999-*.md`).
*
* Matches the unambiguous shell glob `[0-9][0-9][0-9][0-9]-*.md` recorded in
* SPEC-V05-010 and ADR-0021's Errata.
*/
export const ADR_NUMBERED_PATTERN = /^[0-9]{4}-.+\.md$/;
/**
* Required frontmatter keys on every shipping `docs/` page.
*
* Matches `templates/release-package-stub.md`.
*/
export const DOC_STUB_REQUIRED_FRONTMATTER_KEYS: readonly string[] = [
"title",
"folder",
"description",
"entry_point",
];
/**
* Substring that signals an unfilled stub section on a shipping doc.
*
* Built-up codebase docs do not contain this marker; stubified docs do.
*/
export const DOC_STUB_TODO_MARKER = "<!-- TODO:";
/**
* Diagnostic codes emitted by {@link checkReleasePackageContents}.
*/
export const RELEASE_PACKAGE_DIAGNOSTIC_CODES = {
Adr: "RELEASE_PKG_ADR",
Intake: "RELEASE_PKG_INTAKE",
DocStub: "RELEASE_PKG_DOC_STUB",
StubTemplateMissing: "RELEASE_PKG_STUB_TEMPLATE_MISSING",
} as const;
export type ReleasePackageReport = {
archive: string;
diagnostics: Diagnostic[];
};
export type ParsedReleasePackageArgs =
| { archive: string; archiveSource: "argv" | "env" }
| { archiveSource: "none" }
| { archiveSource: "argv-empty"; rawFlag: string };
/**
* Parse CLI arguments for the fresh-surface packaging check.
*
* Recognises `--archive <value>` and `--archive=<value>`. Falls back to the
* `RELEASE_PACKAGE_ARCHIVE` environment variable when no flag is present.
*
* `argv-empty` is reported when the `--archive` flag is present without a
* non-empty value (for example a shell variable that expanded to the empty
* string, or a trailing `--archive` with no following token). The CLI must
* treat that case as an argument error rather than falling through to the
* skip path; otherwise release automation can silently bypass all three
* fresh-surface assertions.
*
* @param argv Arguments after `process.argv.slice(2)`.
* @param env Optional environment object (defaults to `process.env`).
*/
export function parseReleasePackageArgs(
argv: readonly string[],
env: NodeJS.ProcessEnv = process.env,
): ParsedReleasePackageArgs {
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
if (arg === "--archive") {
const value = argv[i + 1];
if (value === undefined || value === "" || value.startsWith("--")) {
return { archiveSource: "argv-empty", rawFlag: arg };
}
return { archive: value, archiveSource: "argv" };
}
if (arg.startsWith("--archive=")) {
const value = arg.slice("--archive=".length);
if (value === "") return { archiveSource: "argv-empty", rawFlag: arg };
return { archive: value, archiveSource: "argv" };
}
}
const envValue = env.RELEASE_PACKAGE_ARCHIVE;
if (envValue) return { archive: envValue, archiveSource: "env" };
return { archiveSource: "none" };
}
/**
* Validate a candidate published archive against the fresh-surface contract
* (ADR-0021 / SPEC-V05-010 / `package-contract.md`).
*
* Three deterministic assertions are evaluated in fixed order: numbered ADRs
* must not ship, intake folders must be empty (or contain only `README.md`),
* and every shipping `docs/` page must match the stub shape from
* `templates/release-package-stub.md`.
*
* @param archive Absolute path to the directory holding the candidate archive.
* @returns Report with the archive path and all violations found.
*/
export function checkReleasePackageContents(archive: string): ReleasePackageReport {
const diagnostics: Diagnostic[] = [];
diagnostics.push(...checkNoNumberedAdrs(archive));
diagnostics.push(...checkIntakeFoldersEmpty(archive));
diagnostics.push(...checkDocsAreStubs(archive));
return { archive, diagnostics };
}
function checkNoNumberedAdrs(archive: string): Diagnostic[] {
const adrDir = path.join(archive, "docs", "adr");
if (!fs.existsSync(adrDir) || !fs.statSync(adrDir).isDirectory()) return [];
const diagnostics: Diagnostic[] = [];
const entries = fs
.readdirSync(adrDir, { withFileTypes: true })
.sort((a, b) => a.name.localeCompare(b.name));
for (const entry of entries) {
if (entry.isFile() && ADR_NUMBERED_PATTERN.test(entry.name)) {
diagnostics.push({
code: RELEASE_PACKAGE_DIAGNOSTIC_CODES.Adr,
path: toPosix(path.join("docs", "adr", entry.name)),
message:
"numbered ADR file must not ship in released package (ADR-0021 §Decision.2 / SPEC-V05-010 assertion 1)",
});
}
}
return diagnostics;
}
function checkIntakeFoldersEmpty(archive: string): Diagnostic[] {
const diagnostics: Diagnostic[] = [];
for (const folder of INTAKE_FOLDERS) {
const folderPath = path.join(archive, folder);
if (!fs.existsSync(folderPath)) continue;
const stat = fs.statSync(folderPath);
if (!stat.isDirectory()) {
diagnostics.push({
code: RELEASE_PACKAGE_DIAGNOSTIC_CODES.Intake,
path: toPosix(folder),
message: `intake folder \`${folder}/\` must be a directory (or absent), got non-directory occupant — must contain only \`README.md\` (ADR-0021 §Decision.3 / SPEC-V05-010 assertion 2)`,
});
continue;
}
const entries = fs
.readdirSync(folderPath, { withFileTypes: true })
.sort((a, b) => a.name.localeCompare(b.name));
for (const entry of entries) {
if (entry.isFile() && entry.name === "README.md") continue;
const relPath =
toPosix(path.join(folder, entry.name)) + (entry.isDirectory() ? "/" : "");
diagnostics.push({
code: RELEASE_PACKAGE_DIAGNOSTIC_CODES.Intake,
path: relPath,
message: `intake folder \`${folder}/\` must be empty or contain only \`README.md\` (ADR-0021 §Decision.3 / SPEC-V05-010 assertion 2)`,
});
}
}
return diagnostics;
}
function checkDocsAreStubs(archive: string): Diagnostic[] {
const diagnostics: Diagnostic[] = [];
const stubTemplate = path.join(archive, "templates", "release-package-stub.md");
if (!fs.existsSync(stubTemplate)) {
diagnostics.push({
code: RELEASE_PACKAGE_DIAGNOSTIC_CODES.StubTemplateMissing,
path: "templates/release-package-stub.md",
message:
"stub template missing from archive — assertion 3 fails closed (SPEC-V05-010 edge case)",
});
return diagnostics;
}
const docsDir = path.join(archive, "docs");
if (!fs.existsSync(docsDir) || !fs.statSync(docsDir).isDirectory()) return diagnostics;
const docs = walkDirectory(docsDir).filter((file) => file.endsWith(".md"));
for (const file of docs) {
const rel = toPosix(path.relative(archive, file));
const artifact = readArtifactSafe(file, DocStubFrontmatterSchema);
if (!artifact || artifact.frontmatterRaw === "") {
diagnostics.push({
code: RELEASE_PACKAGE_DIAGNOSTIC_CODES.DocStub,
path: rel,
message:
"missing frontmatter — shipping doc must match `templates/release-package-stub.md` (SPEC-V05-010 assertion 3)",
});
continue;
}
for (const key of DOC_STUB_REQUIRED_FRONTMATTER_KEYS) {
if (!(key in artifact.data)) {
diagnostics.push({
code: RELEASE_PACKAGE_DIAGNOSTIC_CODES.DocStub,
path: rel,
message: `missing frontmatter key: ${key} (SPEC-V05-010 assertion 3)`,
});
}
}
if (!/^# /m.test(artifact.body)) {
diagnostics.push({
code: RELEASE_PACKAGE_DIAGNOSTIC_CODES.DocStub,
path: rel,
message:
"missing top-level `# ` heading — stub shape requires title heading (SPEC-V05-010 assertion 3)",
});
}
if (!artifact.body.includes(DOC_STUB_TODO_MARKER)) {
diagnostics.push({
code: RELEASE_PACKAGE_DIAGNOSTIC_CODES.DocStub,
path: rel,
message:
"missing stub marker `<!-- TODO:` — built-up content must be replaced with TODO markers per `templates/release-package-stub.md` (SPEC-V05-010 assertion 3)",
});
}
}
return diagnostics;
}
function walkDirectory(start: string): string[] {
const out: string[] = [];
function walk(current: string): void {
const entries = fs
.readdirSync(current, { withFileTypes: true })
.sort((a, b) => a.name.localeCompare(b.name));
for (const entry of entries) {
const full = path.join(current, entry.name);
if (entry.isDirectory()) walk(full);
else if (entry.isFile()) out.push(full);
}
}
walk(start);
return out;
}