specgov 0.1.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.
Files changed (49) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/LICENSE +22 -0
  3. package/README.md +389 -0
  4. package/RELEASING.md +50 -0
  5. package/SECURITY.md +14 -0
  6. package/action.yml +36 -0
  7. package/dist/action/action.d.ts +1 -0
  8. package/dist/action/artifacts.d.ts +6 -0
  9. package/dist/action/checks.d.ts +16 -0
  10. package/dist/action/cli-app.d.ts +5 -0
  11. package/dist/action/cli.d.ts +2 -0
  12. package/dist/action/errors.d.ts +4 -0
  13. package/dist/action/git.d.ts +7 -0
  14. package/dist/action/index.d.ts +5 -0
  15. package/dist/action/index.js +42 -0
  16. package/dist/action/licenses.txt +561 -0
  17. package/dist/action/manifest.d.ts +6 -0
  18. package/dist/action/match.d.ts +1 -0
  19. package/dist/action/package.json +3 -0
  20. package/dist/action/paths.d.ts +3 -0
  21. package/dist/action/report.d.ts +6 -0
  22. package/dist/action/types.d.ts +84 -0
  23. package/dist/action.d.ts +1 -0
  24. package/dist/action.js +45 -0
  25. package/dist/artifacts.d.ts +6 -0
  26. package/dist/artifacts.js +151 -0
  27. package/dist/checks.d.ts +16 -0
  28. package/dist/checks.js +121 -0
  29. package/dist/cli-app.d.ts +5 -0
  30. package/dist/cli-app.js +125 -0
  31. package/dist/cli.d.ts +2 -0
  32. package/dist/cli.js +7 -0
  33. package/dist/errors.d.ts +4 -0
  34. package/dist/errors.js +8 -0
  35. package/dist/git.d.ts +7 -0
  36. package/dist/git.js +47 -0
  37. package/dist/index.d.ts +5 -0
  38. package/dist/index.js +5 -0
  39. package/dist/manifest.d.ts +6 -0
  40. package/dist/manifest.js +232 -0
  41. package/dist/match.d.ts +1 -0
  42. package/dist/match.js +9 -0
  43. package/dist/paths.d.ts +3 -0
  44. package/dist/paths.js +13 -0
  45. package/dist/report.d.ts +6 -0
  46. package/dist/report.js +95 -0
  47. package/dist/types.d.ts +84 -0
  48. package/dist/types.js +1 -0
  49. package/package.json +71 -0
@@ -0,0 +1,232 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { parse } from "yaml";
4
+ import { SpecGovError } from "./errors.js";
5
+ export const DEFAULT_CONFIG_PATH = ".specgov.yml";
6
+ const DEFAULT_RULES = {
7
+ require_spec_impact_for_code_changes: true,
8
+ require_lifecycle_status: false,
9
+ require_owner_for_active_specs: false,
10
+ stale_after_days: 180,
11
+ };
12
+ export const DEFAULT_CONFIG_TEMPLATE = `version: 1
13
+ mode: advisory
14
+
15
+ artifacts:
16
+ - path: "docs/**/*.md"
17
+ kind: documentation
18
+ owner: docs
19
+ - path: "adr/**/*.md"
20
+ kind: decision
21
+ owner: architecture
22
+ - path: ".specs/**/*.md"
23
+ kind: specification
24
+ owner: engineering
25
+
26
+ mappings:
27
+ - code: "src/**"
28
+ specs:
29
+ - "docs/**"
30
+ - "adr/**"
31
+ - ".specs/**"
32
+
33
+ rules:
34
+ require_spec_impact_for_code_changes: true
35
+ require_lifecycle_status: false
36
+ require_owner_for_active_specs: false
37
+ stale_after_days: 180
38
+
39
+ ignore:
40
+ - "node_modules/**"
41
+ - "dist/**"
42
+ - ".git/**"
43
+ `;
44
+ export async function loadConfig(cwd, configPath = DEFAULT_CONFIG_PATH) {
45
+ const absolutePath = path.resolve(cwd, configPath);
46
+ let text;
47
+ try {
48
+ text = await fs.readFile(absolutePath, "utf8");
49
+ }
50
+ catch (error) {
51
+ const nodeError = error;
52
+ if (nodeError.code === "ENOENT") {
53
+ throw new SpecGovError(`SpecGov manifest not found at ${configPath}. Run "specgov init" first.`);
54
+ }
55
+ throw error;
56
+ }
57
+ let parsed;
58
+ try {
59
+ parsed = parse(text);
60
+ }
61
+ catch (error) {
62
+ throw new SpecGovError(`Invalid YAML in ${configPath}: ${error.message}`);
63
+ }
64
+ return normalizeConfig(parsed, configPath);
65
+ }
66
+ export async function writeDefaultConfig(cwd, configPath = DEFAULT_CONFIG_PATH, force = false) {
67
+ const absolutePath = path.resolve(cwd, configPath);
68
+ if (!force) {
69
+ try {
70
+ await fs.access(absolutePath);
71
+ throw new SpecGovError(`SpecGov manifest already exists at ${configPath}. Use --force to overwrite.`);
72
+ }
73
+ catch (error) {
74
+ if (error instanceof SpecGovError) {
75
+ throw error;
76
+ }
77
+ const nodeError = error;
78
+ if (nodeError.code !== "ENOENT") {
79
+ throw error;
80
+ }
81
+ }
82
+ }
83
+ await fs.writeFile(absolutePath, DEFAULT_CONFIG_TEMPLATE, "utf8");
84
+ }
85
+ export function normalizeConfig(input, source = DEFAULT_CONFIG_PATH) {
86
+ if (!input || typeof input !== "object" || Array.isArray(input)) {
87
+ throw new SpecGovError(`Invalid ${source}: expected a YAML object.`);
88
+ }
89
+ const raw = input;
90
+ return {
91
+ version: normalizeVersion(raw.version),
92
+ mode: normalizeMode(raw.mode),
93
+ artifacts: normalizeArtifacts(raw.artifacts),
94
+ mappings: normalizeMappings(raw.mappings),
95
+ rules: normalizeRules(raw.rules),
96
+ ignore: normalizeStringArray(raw.ignore, "ignore", true),
97
+ };
98
+ }
99
+ function normalizeVersion(value) {
100
+ if (value === undefined) {
101
+ return 1;
102
+ }
103
+ if (typeof value !== "number" || !Number.isInteger(value) || value < 1) {
104
+ throw new SpecGovError("Invalid manifest: version must be a positive integer.");
105
+ }
106
+ return value;
107
+ }
108
+ function normalizeMode(value) {
109
+ if (value === undefined) {
110
+ return "advisory";
111
+ }
112
+ if (value === "advisory" || value === "strict") {
113
+ return value;
114
+ }
115
+ throw new SpecGovError('Invalid manifest: mode must be "advisory" or "strict".');
116
+ }
117
+ function normalizeRules(value) {
118
+ if (value === undefined) {
119
+ return { ...DEFAULT_RULES };
120
+ }
121
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
122
+ throw new SpecGovError("Invalid manifest: rules must be an object.");
123
+ }
124
+ const raw = value;
125
+ return {
126
+ require_spec_impact_for_code_changes: boolOrDefault(raw.require_spec_impact_for_code_changes, DEFAULT_RULES.require_spec_impact_for_code_changes, "rules.require_spec_impact_for_code_changes"),
127
+ require_lifecycle_status: boolOrDefault(raw.require_lifecycle_status, DEFAULT_RULES.require_lifecycle_status, "rules.require_lifecycle_status"),
128
+ require_owner_for_active_specs: boolOrDefault(raw.require_owner_for_active_specs, DEFAULT_RULES.require_owner_for_active_specs, "rules.require_owner_for_active_specs"),
129
+ stale_after_days: numberOrDefault(raw.stale_after_days, DEFAULT_RULES.stale_after_days, "rules.stale_after_days"),
130
+ };
131
+ }
132
+ function normalizeArtifacts(value) {
133
+ if (value === undefined) {
134
+ return [];
135
+ }
136
+ if (!Array.isArray(value)) {
137
+ throw new SpecGovError("Invalid manifest: artifacts must be a list.");
138
+ }
139
+ return value.map((item, index) => {
140
+ if (!item || typeof item !== "object" || Array.isArray(item)) {
141
+ throw new SpecGovError(`Invalid manifest: artifacts[${index}] must be an object.`);
142
+ }
143
+ const raw = item;
144
+ const artifact = {
145
+ path: normalizeStringOrArray(raw.path, `artifacts[${index}].path`),
146
+ kind: stringOrThrow(raw.kind, `artifacts[${index}].kind`),
147
+ };
148
+ if (raw.owner !== undefined) {
149
+ artifact.owner = stringOrThrow(raw.owner, `artifacts[${index}].owner`);
150
+ }
151
+ if (raw.status !== undefined) {
152
+ artifact.status = normalizeStatus(raw.status, `artifacts[${index}].status`);
153
+ }
154
+ return artifact;
155
+ });
156
+ }
157
+ function normalizeMappings(value) {
158
+ if (value === undefined) {
159
+ return [];
160
+ }
161
+ if (!Array.isArray(value)) {
162
+ throw new SpecGovError("Invalid manifest: mappings must be a list.");
163
+ }
164
+ return value.map((item, index) => {
165
+ if (!item || typeof item !== "object" || Array.isArray(item)) {
166
+ throw new SpecGovError(`Invalid manifest: mappings[${index}] must be an object.`);
167
+ }
168
+ const raw = item;
169
+ const mapping = {
170
+ code: normalizeStringOrArray(raw.code, `mappings[${index}].code`),
171
+ specs: normalizeStringOrArray(raw.specs, `mappings[${index}].specs`),
172
+ };
173
+ if (raw.description !== undefined) {
174
+ mapping.description = stringOrThrow(raw.description, `mappings[${index}].description`);
175
+ }
176
+ return mapping;
177
+ });
178
+ }
179
+ function normalizeStatus(value, field) {
180
+ if (value === "draft" ||
181
+ value === "active" ||
182
+ value === "superseded" ||
183
+ value === "deprecated" ||
184
+ value === "archived") {
185
+ return value;
186
+ }
187
+ throw new SpecGovError(`Invalid manifest: ${field} has unsupported lifecycle status.`);
188
+ }
189
+ function normalizeStringOrArray(value, field) {
190
+ if (typeof value === "string" && value.trim()) {
191
+ return value;
192
+ }
193
+ if (Array.isArray(value) &&
194
+ value.every((entry) => typeof entry === "string" && entry.trim())) {
195
+ return value;
196
+ }
197
+ throw new SpecGovError(`Invalid manifest: ${field} must be a string or list of strings.`);
198
+ }
199
+ function normalizeStringArray(value, field, withDefaults = false) {
200
+ if (value === undefined) {
201
+ return withDefaults ? ["node_modules/**", "dist/**", ".git/**"] : [];
202
+ }
203
+ if (Array.isArray(value) &&
204
+ value.every((entry) => typeof entry === "string" && entry.trim())) {
205
+ return value;
206
+ }
207
+ throw new SpecGovError(`Invalid manifest: ${field} must be a list of strings.`);
208
+ }
209
+ function stringOrThrow(value, field) {
210
+ if (typeof value === "string" && value.trim()) {
211
+ return value;
212
+ }
213
+ throw new SpecGovError(`Invalid manifest: ${field} must be a non-empty string.`);
214
+ }
215
+ function boolOrDefault(value, defaultValue, field) {
216
+ if (value === undefined) {
217
+ return defaultValue;
218
+ }
219
+ if (typeof value === "boolean") {
220
+ return value;
221
+ }
222
+ throw new SpecGovError(`Invalid manifest: ${field} must be a boolean.`);
223
+ }
224
+ function numberOrDefault(value, defaultValue, field) {
225
+ if (value === undefined) {
226
+ return defaultValue;
227
+ }
228
+ if (typeof value === "number" && Number.isFinite(value) && value >= 0) {
229
+ return value;
230
+ }
231
+ throw new SpecGovError(`Invalid manifest: ${field} must be a non-negative number.`);
232
+ }
@@ -0,0 +1 @@
1
+ export declare function matchesAny(filePath: string, patterns: string | string[] | undefined): boolean;
package/dist/match.js ADDED
@@ -0,0 +1,9 @@
1
+ import picomatch from "picomatch";
2
+ import { normalizePath, toArray } from "./paths.js";
3
+ export function matchesAny(filePath, patterns) {
4
+ const normalizedPath = normalizePath(filePath);
5
+ return toArray(patterns).some((pattern) => {
6
+ const matcher = picomatch(normalizePath(pattern), { dot: true });
7
+ return matcher(normalizedPath);
8
+ });
9
+ }
@@ -0,0 +1,3 @@
1
+ export declare function normalizePath(input: string): string;
2
+ export declare function relativeToCwd(cwd: string, filePath: string): string;
3
+ export declare function toArray(value: string | string[] | undefined): string[];
package/dist/paths.js ADDED
@@ -0,0 +1,13 @@
1
+ import path from "node:path";
2
+ export function normalizePath(input) {
3
+ return input.replace(/\\/g, "/").replace(/^\.\//, "");
4
+ }
5
+ export function relativeToCwd(cwd, filePath) {
6
+ return normalizePath(path.relative(cwd, filePath));
7
+ }
8
+ export function toArray(value) {
9
+ if (!value) {
10
+ return [];
11
+ }
12
+ return Array.isArray(value) ? value : [value];
13
+ }
@@ -0,0 +1,6 @@
1
+ import type { EnforcementMode, Finding, ReportStatus, SpecGovReport } from "./types.js";
2
+ export declare function evaluateStatus(findings: Finding[], mode: EnforcementMode): ReportStatus;
3
+ export declare function makeReport(input: Omit<SpecGovReport, "status" | "summary">): SpecGovReport;
4
+ export declare function exitCodeForReport(report: SpecGovReport): number;
5
+ export declare function renderReport(report: SpecGovReport, format?: "json" | "markdown"): string;
6
+ export declare function renderMarkdownReport(report: SpecGovReport): string;
package/dist/report.js ADDED
@@ -0,0 +1,95 @@
1
+ export function evaluateStatus(findings, mode) {
2
+ if (findings.some((finding) => finding.severity === "error")) {
3
+ return "fail";
4
+ }
5
+ if (findings.some((finding) => finding.severity === "warning")) {
6
+ return mode === "strict" ? "fail" : "warn";
7
+ }
8
+ return "pass";
9
+ }
10
+ export function makeReport(input) {
11
+ const status = evaluateStatus(input.findings, input.mode);
12
+ return {
13
+ ...input,
14
+ status,
15
+ summary: {
16
+ findings: input.findings.length,
17
+ errors: input.findings.filter((finding) => finding.severity === "error")
18
+ .length,
19
+ warnings: input.findings.filter((finding) => finding.severity === "warning").length,
20
+ infos: input.findings.filter((finding) => finding.severity === "info")
21
+ .length,
22
+ },
23
+ };
24
+ }
25
+ export function exitCodeForReport(report) {
26
+ if (report.status === "error") {
27
+ return 2;
28
+ }
29
+ if (report.status === "fail") {
30
+ return 1;
31
+ }
32
+ return 0;
33
+ }
34
+ export function renderReport(report, format = "markdown") {
35
+ if (format === "json") {
36
+ return `${JSON.stringify(report, null, 2)}\n`;
37
+ }
38
+ return renderMarkdownReport(report);
39
+ }
40
+ export function renderMarkdownReport(report) {
41
+ const lines = [
42
+ `# SpecGov ${report.command} report`,
43
+ "",
44
+ `Status: **${report.status}**`,
45
+ `Mode: \`${report.mode}\``,
46
+ `Findings: ${report.summary.findings} (${report.summary.errors} errors, ${report.summary.warnings} warnings, ${report.summary.infos} info)`,
47
+ "",
48
+ ];
49
+ if (report.changedFiles?.length) {
50
+ lines.push("## Changed Files", "");
51
+ for (const file of report.changedFiles) {
52
+ lines.push(`- \`${file}\``);
53
+ }
54
+ lines.push("");
55
+ }
56
+ if (report.artifacts) {
57
+ lines.push("## Governed Artifacts", "");
58
+ if (report.artifacts.length === 0) {
59
+ lines.push("No governed artifacts were discovered.", "");
60
+ }
61
+ else {
62
+ for (const artifact of report.artifacts) {
63
+ const status = artifact.status ? `, status: ${artifact.status}` : "";
64
+ const owner = artifact.owner ? `, owner: ${artifact.owner}` : "";
65
+ lines.push(`- \`${artifact.path}\` (${artifact.kind}${status}${owner})`);
66
+ }
67
+ lines.push("");
68
+ }
69
+ }
70
+ lines.push("## Findings", "");
71
+ if (report.findings.length === 0) {
72
+ lines.push("No findings.", "");
73
+ }
74
+ else {
75
+ for (const finding of report.findings) {
76
+ lines.push(`- **${finding.severity.toUpperCase()} ${finding.code}**: ${finding.message}`);
77
+ if (finding.file) {
78
+ lines.push(` - File: \`${finding.file}\``);
79
+ }
80
+ if (finding.relatedFiles?.length) {
81
+ lines.push(` - Related: ${finding.relatedFiles.map((file) => `\`${file}\``).join(", ")}`);
82
+ }
83
+ if (finding.suggestion) {
84
+ lines.push(` - Suggestion: ${finding.suggestion}`);
85
+ }
86
+ }
87
+ lines.push("");
88
+ }
89
+ if (report.trace) {
90
+ lines.push("## Trace Summary", "");
91
+ lines.push(`Artifacts: ${report.trace.artifacts.length}`);
92
+ lines.push(`Mappings: ${report.trace.mappings.length}`, "");
93
+ }
94
+ return `${lines.join("\n")}\n`;
95
+ }
@@ -0,0 +1,84 @@
1
+ export type EnforcementMode = "advisory" | "strict";
2
+ export type ArtifactStatus = "draft" | "active" | "superseded" | "deprecated" | "archived";
3
+ export type ReportStatus = "pass" | "warn" | "fail" | "error";
4
+ export type Severity = "info" | "warning" | "error";
5
+ export interface ArtifactRule {
6
+ path: string | string[];
7
+ kind: string;
8
+ owner?: string;
9
+ status?: ArtifactStatus;
10
+ }
11
+ export interface MappingRule {
12
+ code: string | string[];
13
+ specs: string | string[];
14
+ description?: string;
15
+ }
16
+ export interface GovernanceRules {
17
+ require_spec_impact_for_code_changes: boolean;
18
+ require_lifecycle_status: boolean;
19
+ require_owner_for_active_specs: boolean;
20
+ stale_after_days: number;
21
+ }
22
+ export interface SpecGovConfig {
23
+ version: number;
24
+ mode: EnforcementMode;
25
+ artifacts: ArtifactRule[];
26
+ mappings: MappingRule[];
27
+ rules: GovernanceRules;
28
+ ignore: string[];
29
+ }
30
+ export interface ArtifactMetadata {
31
+ status?: ArtifactStatus;
32
+ owner?: string;
33
+ last_verified?: string;
34
+ superseded_by?: string;
35
+ }
36
+ export interface GovernedArtifact {
37
+ path: string;
38
+ kind: string;
39
+ owner?: string;
40
+ status?: ArtifactStatus;
41
+ lastVerified?: string;
42
+ supersededBy?: string;
43
+ }
44
+ export interface ArtifactDiscovery {
45
+ artifacts: GovernedArtifact[];
46
+ ruleMatchCounts: Array<{
47
+ path: string | string[];
48
+ kind: string;
49
+ count: number;
50
+ }>;
51
+ }
52
+ export interface Finding {
53
+ code: string;
54
+ severity: Severity;
55
+ message: string;
56
+ file?: string;
57
+ relatedFiles?: string[];
58
+ suggestion?: string;
59
+ }
60
+ export interface SpecGovReport {
61
+ command: string;
62
+ status: ReportStatus;
63
+ mode: EnforcementMode;
64
+ summary: {
65
+ findings: number;
66
+ errors: number;
67
+ warnings: number;
68
+ infos: number;
69
+ };
70
+ findings: Finding[];
71
+ artifacts?: GovernedArtifact[];
72
+ changedFiles?: string[];
73
+ trace?: TraceIndex;
74
+ }
75
+ export interface TraceIndex {
76
+ generatedAt: string;
77
+ artifacts: GovernedArtifact[];
78
+ mappings: Array<{
79
+ code: string[];
80
+ specs: string[];
81
+ matchedArtifacts: string[];
82
+ description?: string;
83
+ }>;
84
+ }
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/package.json ADDED
@@ -0,0 +1,71 @@
1
+ {
2
+ "name": "specgov",
3
+ "version": "0.1.0",
4
+ "description": "Spec governance layer for Git repositories.",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "bin": {
8
+ "specgov": "dist/cli.js"
9
+ },
10
+ "files": [
11
+ "dist",
12
+ "action.yml",
13
+ "README.md",
14
+ "RELEASING.md",
15
+ "SECURITY.md",
16
+ "CHANGELOG.md",
17
+ "LICENSE"
18
+ ],
19
+ "scripts": {
20
+ "clean": "node -e \"require('fs').rmSync('dist', { recursive: true, force: true })\"",
21
+ "build": "npm run clean && tsc -p tsconfig.json && npm run build:action",
22
+ "build:action": "ncc build src/action.ts -o dist/action -m --license licenses.txt",
23
+ "prepack": "npm run build",
24
+ "test": "vitest run",
25
+ "lint": "eslint .",
26
+ "typecheck": "tsc -p tsconfig.json --noEmit",
27
+ "format:check": "prettier --check ."
28
+ },
29
+ "keywords": [
30
+ "specs",
31
+ "governance",
32
+ "git",
33
+ "requirements",
34
+ "traceability",
35
+ "github-action"
36
+ ],
37
+ "author": "Fernando Paladini",
38
+ "license": "MIT",
39
+ "repository": {
40
+ "type": "git",
41
+ "url": "git+https://github.com/paladini/specgov.git"
42
+ },
43
+ "bugs": {
44
+ "url": "https://github.com/paladini/specgov/issues"
45
+ },
46
+ "homepage": "https://github.com/paladini/specgov#readme",
47
+ "publishConfig": {
48
+ "access": "public"
49
+ },
50
+ "engines": {
51
+ "node": ">=20"
52
+ },
53
+ "dependencies": {
54
+ "@actions/core": "^3.0.1",
55
+ "commander": "^15.0.0",
56
+ "fast-glob": "^3.3.3",
57
+ "picomatch": "^4.0.4",
58
+ "yaml": "^2.9.0"
59
+ },
60
+ "devDependencies": {
61
+ "@eslint/js": "^10.0.1",
62
+ "@types/node": "^26.0.1",
63
+ "@types/picomatch": "^4.0.3",
64
+ "@vercel/ncc": "^0.44.0",
65
+ "eslint": "^10.6.0",
66
+ "prettier": "^3.9.1",
67
+ "typescript": "^6.0.3",
68
+ "typescript-eslint": "^8.62.0",
69
+ "vitest": "^4.1.9"
70
+ }
71
+ }