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.
- package/CHANGELOG.md +8 -0
- package/LICENSE +22 -0
- package/README.md +389 -0
- package/RELEASING.md +50 -0
- package/SECURITY.md +14 -0
- package/action.yml +36 -0
- package/dist/action/action.d.ts +1 -0
- package/dist/action/artifacts.d.ts +6 -0
- package/dist/action/checks.d.ts +16 -0
- package/dist/action/cli-app.d.ts +5 -0
- package/dist/action/cli.d.ts +2 -0
- package/dist/action/errors.d.ts +4 -0
- package/dist/action/git.d.ts +7 -0
- package/dist/action/index.d.ts +5 -0
- package/dist/action/index.js +42 -0
- package/dist/action/licenses.txt +561 -0
- package/dist/action/manifest.d.ts +6 -0
- package/dist/action/match.d.ts +1 -0
- package/dist/action/package.json +3 -0
- package/dist/action/paths.d.ts +3 -0
- package/dist/action/report.d.ts +6 -0
- package/dist/action/types.d.ts +84 -0
- package/dist/action.d.ts +1 -0
- package/dist/action.js +45 -0
- package/dist/artifacts.d.ts +6 -0
- package/dist/artifacts.js +151 -0
- package/dist/checks.d.ts +16 -0
- package/dist/checks.js +121 -0
- package/dist/cli-app.d.ts +5 -0
- package/dist/cli-app.js +125 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +7 -0
- package/dist/errors.d.ts +4 -0
- package/dist/errors.js +8 -0
- package/dist/git.d.ts +7 -0
- package/dist/git.js +47 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +5 -0
- package/dist/manifest.d.ts +6 -0
- package/dist/manifest.js +232 -0
- package/dist/match.d.ts +1 -0
- package/dist/match.js +9 -0
- package/dist/paths.d.ts +3 -0
- package/dist/paths.js +13 -0
- package/dist/report.d.ts +6 -0
- package/dist/report.js +95 -0
- package/dist/types.d.ts +84 -0
- package/dist/types.js +1 -0
- package/package.json +71 -0
package/dist/action.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import * as core from "@actions/core";
|
|
2
|
+
import { runCheckPr } from "./checks.js";
|
|
3
|
+
import { loadConfig } from "./manifest.js";
|
|
4
|
+
import { renderMarkdownReport } from "./report.js";
|
|
5
|
+
async function main() {
|
|
6
|
+
try {
|
|
7
|
+
const cwd = process.cwd();
|
|
8
|
+
const configPath = core.getInput("config") || ".specgov.yml";
|
|
9
|
+
const modeInput = core.getInput("mode") || undefined;
|
|
10
|
+
const mode = modeInput ? parseMode(modeInput) : undefined;
|
|
11
|
+
const baseRef = core.getInput("base-ref") || undefined;
|
|
12
|
+
const headRef = core.getInput("head-ref") || undefined;
|
|
13
|
+
const changedFiles = core
|
|
14
|
+
.getMultilineInput("changed-files")
|
|
15
|
+
.filter(Boolean);
|
|
16
|
+
const config = await loadConfig(cwd, configPath);
|
|
17
|
+
const report = await runCheckPr({
|
|
18
|
+
cwd,
|
|
19
|
+
config,
|
|
20
|
+
mode,
|
|
21
|
+
baseRef,
|
|
22
|
+
headRef,
|
|
23
|
+
changedFiles,
|
|
24
|
+
});
|
|
25
|
+
const markdown = renderMarkdownReport(report);
|
|
26
|
+
await core.summary.addRaw(markdown).write();
|
|
27
|
+
core.info(markdown);
|
|
28
|
+
core.setOutput("status", report.status);
|
|
29
|
+
core.setOutput("report-json", JSON.stringify(report));
|
|
30
|
+
if (report.status === "fail") {
|
|
31
|
+
core.setFailed(`SpecGov failed with ${report.summary.findings} finding(s).`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
catch (error) {
|
|
35
|
+
core.setOutput("status", "error");
|
|
36
|
+
core.setFailed(error.message);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
function parseMode(value) {
|
|
40
|
+
if (value === "advisory" || value === "strict") {
|
|
41
|
+
return value;
|
|
42
|
+
}
|
|
43
|
+
throw new Error('mode must be "advisory" or "strict".');
|
|
44
|
+
}
|
|
45
|
+
await main();
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { ArtifactDiscovery, ArtifactMetadata, Finding, GovernedArtifact, SpecGovConfig } from "./types.js";
|
|
2
|
+
export declare function discoverArtifacts(config: SpecGovConfig, cwd: string): Promise<ArtifactDiscovery>;
|
|
3
|
+
export declare function readArtifactMetadata(filePath: string): Promise<ArtifactMetadata>;
|
|
4
|
+
export declare function buildArtifactFindings(config: SpecGovConfig, discovery: ArtifactDiscovery, now?: Date): Finding[];
|
|
5
|
+
export declare function artifactPathsMatching(artifacts: GovernedArtifact[], patterns: string | string[]): string[];
|
|
6
|
+
export declare function isArtifactPath(config: SpecGovConfig, filePath: string): boolean;
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import fg from "fast-glob";
|
|
4
|
+
import { parse } from "yaml";
|
|
5
|
+
import { matchesAny } from "./match.js";
|
|
6
|
+
import { normalizePath, toArray } from "./paths.js";
|
|
7
|
+
const FRONTMATTER = /^---\r?\n([\s\S]*?)\r?\n---/;
|
|
8
|
+
const STATUS_VALUES = [
|
|
9
|
+
"draft",
|
|
10
|
+
"active",
|
|
11
|
+
"superseded",
|
|
12
|
+
"deprecated",
|
|
13
|
+
"archived",
|
|
14
|
+
];
|
|
15
|
+
export async function discoverArtifacts(config, cwd) {
|
|
16
|
+
const artifactsByPath = new Map();
|
|
17
|
+
const ruleMatchCounts = [];
|
|
18
|
+
for (const rule of config.artifacts) {
|
|
19
|
+
const entries = await fg(toArray(rule.path), {
|
|
20
|
+
cwd,
|
|
21
|
+
onlyFiles: true,
|
|
22
|
+
dot: true,
|
|
23
|
+
ignore: config.ignore,
|
|
24
|
+
});
|
|
25
|
+
ruleMatchCounts.push({
|
|
26
|
+
path: rule.path,
|
|
27
|
+
kind: rule.kind,
|
|
28
|
+
count: entries.length,
|
|
29
|
+
});
|
|
30
|
+
for (const entry of entries) {
|
|
31
|
+
const relativePath = normalizePath(entry);
|
|
32
|
+
if (artifactsByPath.has(relativePath)) {
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
const metadata = await readArtifactMetadata(path.resolve(cwd, entry));
|
|
36
|
+
artifactsByPath.set(relativePath, {
|
|
37
|
+
path: relativePath,
|
|
38
|
+
kind: rule.kind,
|
|
39
|
+
owner: metadata.owner ?? rule.owner,
|
|
40
|
+
status: metadata.status ?? rule.status,
|
|
41
|
+
lastVerified: metadata.last_verified,
|
|
42
|
+
supersededBy: metadata.superseded_by,
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return {
|
|
47
|
+
artifacts: [...artifactsByPath.values()].sort((left, right) => left.path.localeCompare(right.path)),
|
|
48
|
+
ruleMatchCounts,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
export async function readArtifactMetadata(filePath) {
|
|
52
|
+
const text = await fs.readFile(filePath, "utf8");
|
|
53
|
+
const match = text.match(FRONTMATTER);
|
|
54
|
+
if (!match?.[1]) {
|
|
55
|
+
return {};
|
|
56
|
+
}
|
|
57
|
+
const parsed = parse(match[1]);
|
|
58
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
59
|
+
return {};
|
|
60
|
+
}
|
|
61
|
+
const raw = parsed;
|
|
62
|
+
const metadata = {};
|
|
63
|
+
if (typeof raw.status === "string" &&
|
|
64
|
+
STATUS_VALUES.includes(raw.status)) {
|
|
65
|
+
metadata.status = raw.status;
|
|
66
|
+
}
|
|
67
|
+
if (typeof raw.owner === "string") {
|
|
68
|
+
metadata.owner = raw.owner;
|
|
69
|
+
}
|
|
70
|
+
if (typeof raw.last_verified === "string") {
|
|
71
|
+
metadata.last_verified = raw.last_verified;
|
|
72
|
+
}
|
|
73
|
+
if (typeof raw.superseded_by === "string") {
|
|
74
|
+
metadata.superseded_by = raw.superseded_by;
|
|
75
|
+
}
|
|
76
|
+
return metadata;
|
|
77
|
+
}
|
|
78
|
+
export function buildArtifactFindings(config, discovery, now = new Date()) {
|
|
79
|
+
const findings = [];
|
|
80
|
+
for (const rule of discovery.ruleMatchCounts) {
|
|
81
|
+
if (rule.count === 0) {
|
|
82
|
+
findings.push({
|
|
83
|
+
code: "ARTIFACT_GLOB_EMPTY",
|
|
84
|
+
severity: "warning",
|
|
85
|
+
message: `Artifact glob for ${rule.kind} matched no files.`,
|
|
86
|
+
relatedFiles: toArray(rule.path),
|
|
87
|
+
suggestion: "Update the glob or add the governed artifact files.",
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
for (const artifact of discovery.artifacts) {
|
|
92
|
+
if (config.rules.require_lifecycle_status && !artifact.status) {
|
|
93
|
+
findings.push({
|
|
94
|
+
code: "LIFECYCLE_STATUS_MISSING",
|
|
95
|
+
severity: "warning",
|
|
96
|
+
message: `Governed artifact ${artifact.path} has no lifecycle status.`,
|
|
97
|
+
file: artifact.path,
|
|
98
|
+
suggestion: "Add YAML frontmatter with status: active, draft, superseded, deprecated, or archived.",
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
if (config.rules.require_owner_for_active_specs &&
|
|
102
|
+
artifact.status === "active" &&
|
|
103
|
+
!artifact.owner) {
|
|
104
|
+
findings.push({
|
|
105
|
+
code: "ACTIVE_OWNER_MISSING",
|
|
106
|
+
severity: "warning",
|
|
107
|
+
message: `Active governed artifact ${artifact.path} has no owner.`,
|
|
108
|
+
file: artifact.path,
|
|
109
|
+
suggestion: "Add owner metadata in frontmatter or the artifact rule.",
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
if (artifact.status === "superseded" && !artifact.supersededBy) {
|
|
113
|
+
findings.push({
|
|
114
|
+
code: "SUPERSEDED_TARGET_MISSING",
|
|
115
|
+
severity: "warning",
|
|
116
|
+
message: `Superseded artifact ${artifact.path} does not declare superseded_by.`,
|
|
117
|
+
file: artifact.path,
|
|
118
|
+
suggestion: "Add superseded_by metadata pointing to the replacement artifact.",
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
if (artifact.lastVerified && config.rules.stale_after_days > 0) {
|
|
122
|
+
const ageDays = daysBetween(artifact.lastVerified, now);
|
|
123
|
+
if (ageDays !== undefined && ageDays > config.rules.stale_after_days) {
|
|
124
|
+
findings.push({
|
|
125
|
+
code: "ARTIFACT_STALE",
|
|
126
|
+
severity: "warning",
|
|
127
|
+
message: `Governed artifact ${artifact.path} was last verified ${ageDays} days ago.`,
|
|
128
|
+
file: artifact.path,
|
|
129
|
+
suggestion: "Review the artifact and update last_verified if it still matches reality.",
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return findings;
|
|
135
|
+
}
|
|
136
|
+
export function artifactPathsMatching(artifacts, patterns) {
|
|
137
|
+
return artifacts
|
|
138
|
+
.filter((artifact) => matchesAny(artifact.path, patterns))
|
|
139
|
+
.map((artifact) => artifact.path);
|
|
140
|
+
}
|
|
141
|
+
export function isArtifactPath(config, filePath) {
|
|
142
|
+
return config.artifacts.some((rule) => matchesAny(filePath, rule.path));
|
|
143
|
+
}
|
|
144
|
+
function daysBetween(dateText, now) {
|
|
145
|
+
const parsed = new Date(`${dateText}T00:00:00Z`);
|
|
146
|
+
if (Number.isNaN(parsed.getTime())) {
|
|
147
|
+
return undefined;
|
|
148
|
+
}
|
|
149
|
+
const diffMs = now.getTime() - parsed.getTime();
|
|
150
|
+
return Math.floor(diffMs / 86_400_000);
|
|
151
|
+
}
|
package/dist/checks.d.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { SpecGovConfig, SpecGovReport, TraceIndex } from "./types.js";
|
|
2
|
+
interface BaseOptions {
|
|
3
|
+
cwd: string;
|
|
4
|
+
config: SpecGovConfig;
|
|
5
|
+
}
|
|
6
|
+
export interface CheckPrOptions extends BaseOptions {
|
|
7
|
+
baseRef?: string;
|
|
8
|
+
headRef?: string;
|
|
9
|
+
changedFiles?: string[];
|
|
10
|
+
mode?: "advisory" | "strict";
|
|
11
|
+
}
|
|
12
|
+
export declare function runScan(options: BaseOptions): Promise<SpecGovReport>;
|
|
13
|
+
export declare function runCheckPr(options: CheckPrOptions): Promise<SpecGovReport>;
|
|
14
|
+
export declare function runTrace(options: BaseOptions): Promise<TraceIndex>;
|
|
15
|
+
export declare function runDrift(options: BaseOptions): Promise<SpecGovReport>;
|
|
16
|
+
export {};
|
package/dist/checks.js
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { artifactPathsMatching, buildArtifactFindings, discoverArtifacts, isArtifactPath, } from "./artifacts.js";
|
|
2
|
+
import { getChangedFiles } from "./git.js";
|
|
3
|
+
import { matchesAny } from "./match.js";
|
|
4
|
+
import { toArray } from "./paths.js";
|
|
5
|
+
import { makeReport } from "./report.js";
|
|
6
|
+
export async function runScan(options) {
|
|
7
|
+
const discovery = await discoverArtifacts(options.config, options.cwd);
|
|
8
|
+
const findings = buildArtifactFindings(options.config, discovery);
|
|
9
|
+
return makeReport({
|
|
10
|
+
command: "scan",
|
|
11
|
+
mode: options.config.mode,
|
|
12
|
+
findings,
|
|
13
|
+
artifacts: discovery.artifacts,
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
export async function runCheckPr(options) {
|
|
17
|
+
const config = options.mode
|
|
18
|
+
? { ...options.config, mode: options.mode }
|
|
19
|
+
: options.config;
|
|
20
|
+
const discovery = await discoverArtifacts(config, options.cwd);
|
|
21
|
+
const changedFiles = await getChangedFiles({
|
|
22
|
+
cwd: options.cwd,
|
|
23
|
+
baseRef: options.baseRef,
|
|
24
|
+
headRef: options.headRef,
|
|
25
|
+
explicitFiles: options.changedFiles,
|
|
26
|
+
});
|
|
27
|
+
const filteredChangedFiles = changedFiles.filter((file) => !matchesAny(file, config.ignore));
|
|
28
|
+
const findings = [
|
|
29
|
+
...buildArtifactFindings(config, discovery),
|
|
30
|
+
...buildImpactFindings(config, filteredChangedFiles),
|
|
31
|
+
];
|
|
32
|
+
return makeReport({
|
|
33
|
+
command: "check-pr",
|
|
34
|
+
mode: config.mode,
|
|
35
|
+
findings,
|
|
36
|
+
artifacts: discovery.artifacts,
|
|
37
|
+
changedFiles: filteredChangedFiles,
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
export async function runTrace(options) {
|
|
41
|
+
const discovery = await discoverArtifacts(options.config, options.cwd);
|
|
42
|
+
return {
|
|
43
|
+
generatedAt: new Date().toISOString(),
|
|
44
|
+
artifacts: discovery.artifacts,
|
|
45
|
+
mappings: options.config.mappings.map((mapping) => ({
|
|
46
|
+
code: toArray(mapping.code),
|
|
47
|
+
specs: toArray(mapping.specs),
|
|
48
|
+
matchedArtifacts: artifactPathsMatching(discovery.artifacts, mapping.specs),
|
|
49
|
+
description: mapping.description,
|
|
50
|
+
})),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
export async function runDrift(options) {
|
|
54
|
+
const discovery = await discoverArtifacts(options.config, options.cwd);
|
|
55
|
+
const trace = await runTrace(options);
|
|
56
|
+
const findings = [
|
|
57
|
+
...buildArtifactFindings(options.config, discovery),
|
|
58
|
+
...buildOrphanFindings(options.config, discovery.artifacts.map((artifact) => artifact.path)),
|
|
59
|
+
];
|
|
60
|
+
return makeReport({
|
|
61
|
+
command: "drift",
|
|
62
|
+
mode: options.config.mode,
|
|
63
|
+
findings,
|
|
64
|
+
artifacts: discovery.artifacts,
|
|
65
|
+
trace,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
function buildImpactFindings(config, changedFiles) {
|
|
69
|
+
const findings = [];
|
|
70
|
+
const mappedCodeFiles = new Set();
|
|
71
|
+
for (const mapping of config.mappings) {
|
|
72
|
+
const changedCode = changedFiles.filter((file) => matchesAny(file, mapping.code));
|
|
73
|
+
if (changedCode.length === 0) {
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
for (const file of changedCode) {
|
|
77
|
+
mappedCodeFiles.add(file);
|
|
78
|
+
}
|
|
79
|
+
const changedSpecs = changedFiles.filter((file) => matchesAny(file, mapping.specs));
|
|
80
|
+
if (changedSpecs.length === 0) {
|
|
81
|
+
findings.push({
|
|
82
|
+
code: "SPEC_IMPACT_MISSING",
|
|
83
|
+
severity: "warning",
|
|
84
|
+
message: `Code changed under ${toArray(mapping.code).join(", ")} without a related spec artifact change.`,
|
|
85
|
+
relatedFiles: [...changedCode, ...toArray(mapping.specs)],
|
|
86
|
+
suggestion: "Update a mapped spec artifact or run in advisory mode until this mapping is ready to enforce.",
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
if (config.rules.require_spec_impact_for_code_changes) {
|
|
91
|
+
for (const file of changedFiles) {
|
|
92
|
+
const isMapped = mappedCodeFiles.has(file);
|
|
93
|
+
const isGovernedArtifact = isArtifactPath(config, file);
|
|
94
|
+
const isManifest = file === ".specgov.yml";
|
|
95
|
+
if (!isMapped && !isGovernedArtifact && !isManifest) {
|
|
96
|
+
findings.push({
|
|
97
|
+
code: "CODE_CHANGE_UNMAPPED",
|
|
98
|
+
severity: "warning",
|
|
99
|
+
message: `Changed file ${file} is not covered by a code-to-spec mapping.`,
|
|
100
|
+
file,
|
|
101
|
+
suggestion: "Add a mapping for this code area or ignore it explicitly.",
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return findings;
|
|
107
|
+
}
|
|
108
|
+
function buildOrphanFindings(config, artifactPaths) {
|
|
109
|
+
if (config.mappings.length === 0) {
|
|
110
|
+
return [];
|
|
111
|
+
}
|
|
112
|
+
return artifactPaths
|
|
113
|
+
.filter((artifactPath) => !config.mappings.some((mapping) => matchesAny(artifactPath, mapping.specs)))
|
|
114
|
+
.map((artifactPath) => ({
|
|
115
|
+
code: "ARTIFACT_ORPHANED",
|
|
116
|
+
severity: "info",
|
|
117
|
+
message: `Governed artifact ${artifactPath} is not referenced by any mapping.`,
|
|
118
|
+
file: artifactPath,
|
|
119
|
+
suggestion: "Reference this artifact from a mapping if it should participate in PR impact checks.",
|
|
120
|
+
}));
|
|
121
|
+
}
|
package/dist/cli-app.js
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { Command, InvalidArgumentError } from "commander";
|
|
4
|
+
import { runCheckPr, runDrift, runScan, runTrace } from "./checks.js";
|
|
5
|
+
import { SpecGovError } from "./errors.js";
|
|
6
|
+
import { DEFAULT_CONFIG_PATH, loadConfig, writeDefaultConfig, } from "./manifest.js";
|
|
7
|
+
import { exitCodeForReport, renderReport } from "./report.js";
|
|
8
|
+
export async function runCli(argv, cwd, io) {
|
|
9
|
+
const program = new Command();
|
|
10
|
+
let exitCode = 0;
|
|
11
|
+
program
|
|
12
|
+
.name("specgov")
|
|
13
|
+
.description("Spec governance layer for Git repositories.")
|
|
14
|
+
.version("0.1.0")
|
|
15
|
+
.showHelpAfterError()
|
|
16
|
+
.exitOverride();
|
|
17
|
+
program.configureOutput({
|
|
18
|
+
writeOut: (text) => io.stdout(text),
|
|
19
|
+
writeErr: (text) => io.stderr(text),
|
|
20
|
+
});
|
|
21
|
+
program
|
|
22
|
+
.command("init")
|
|
23
|
+
.description("Create a .specgov.yml template.")
|
|
24
|
+
.option("-c, --config <path>", "Manifest path.", DEFAULT_CONFIG_PATH)
|
|
25
|
+
.option("--force", "Overwrite an existing manifest.", false)
|
|
26
|
+
.action(async (options) => {
|
|
27
|
+
await writeDefaultConfig(cwd, options.config, options.force);
|
|
28
|
+
io.stdout(`Created ${options.config}\n`);
|
|
29
|
+
});
|
|
30
|
+
program
|
|
31
|
+
.command("scan")
|
|
32
|
+
.description("Discover governed artifacts and validate lifecycle metadata.")
|
|
33
|
+
.option("-c, --config <path>", "Manifest path.", DEFAULT_CONFIG_PATH)
|
|
34
|
+
.option("-f, --format <format>", "Output format: markdown or json.", parseFormat, "markdown")
|
|
35
|
+
.action(async (options) => {
|
|
36
|
+
const config = await loadConfig(cwd, options.config);
|
|
37
|
+
const report = await runScan({ cwd, config });
|
|
38
|
+
io.stdout(renderReport(report, options.format));
|
|
39
|
+
exitCode = exitCodeForReport(report);
|
|
40
|
+
});
|
|
41
|
+
program
|
|
42
|
+
.command("check-pr")
|
|
43
|
+
.description("Check changed files for missing spec impact.")
|
|
44
|
+
.option("-c, --config <path>", "Manifest path.", DEFAULT_CONFIG_PATH)
|
|
45
|
+
.option("--base-ref <ref>", "Base git ref for comparison.")
|
|
46
|
+
.option("--head-ref <ref>", "Head git ref for comparison.")
|
|
47
|
+
.option("--changed-file <path>", "Explicit changed file. Repeatable.", collect, [])
|
|
48
|
+
.option("--mode <mode>", "Override enforcement mode: advisory or strict.", parseMode)
|
|
49
|
+
.option("-f, --format <format>", "Output format: markdown or json.", parseFormat, "markdown")
|
|
50
|
+
.action(async (options) => {
|
|
51
|
+
const config = await loadConfig(cwd, options.config);
|
|
52
|
+
const report = await runCheckPr({
|
|
53
|
+
cwd,
|
|
54
|
+
config,
|
|
55
|
+
baseRef: options.baseRef,
|
|
56
|
+
headRef: options.headRef,
|
|
57
|
+
changedFiles: options.changedFile,
|
|
58
|
+
mode: options.mode,
|
|
59
|
+
});
|
|
60
|
+
io.stdout(renderReport(report, options.format));
|
|
61
|
+
exitCode = exitCodeForReport(report);
|
|
62
|
+
});
|
|
63
|
+
program
|
|
64
|
+
.command("trace")
|
|
65
|
+
.description("Generate a machine-readable trace index.")
|
|
66
|
+
.option("-c, --config <path>", "Manifest path.", DEFAULT_CONFIG_PATH)
|
|
67
|
+
.option("--out <path>", "Write trace JSON to a file.")
|
|
68
|
+
.action(async (options) => {
|
|
69
|
+
const config = await loadConfig(cwd, options.config);
|
|
70
|
+
const trace = await runTrace({ cwd, config });
|
|
71
|
+
const text = `${JSON.stringify(trace, null, 2)}\n`;
|
|
72
|
+
if (options.out) {
|
|
73
|
+
await fs.writeFile(path.resolve(cwd, options.out), text, "utf8");
|
|
74
|
+
io.stdout(`Wrote ${options.out}\n`);
|
|
75
|
+
}
|
|
76
|
+
else {
|
|
77
|
+
io.stdout(text);
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
program
|
|
81
|
+
.command("drift")
|
|
82
|
+
.description("Report stale, empty, superseded, and orphaned spec artifacts.")
|
|
83
|
+
.option("-c, --config <path>", "Manifest path.", DEFAULT_CONFIG_PATH)
|
|
84
|
+
.option("-f, --format <format>", "Output format: markdown or json.", parseFormat, "markdown")
|
|
85
|
+
.action(async (options) => {
|
|
86
|
+
const config = await loadConfig(cwd, options.config);
|
|
87
|
+
const report = await runDrift({ cwd, config });
|
|
88
|
+
io.stdout(renderReport(report, options.format));
|
|
89
|
+
exitCode = exitCodeForReport(report);
|
|
90
|
+
});
|
|
91
|
+
try {
|
|
92
|
+
await program.parseAsync(argv, { from: "user" });
|
|
93
|
+
return exitCode;
|
|
94
|
+
}
|
|
95
|
+
catch (error) {
|
|
96
|
+
if (error instanceof SpecGovError) {
|
|
97
|
+
io.stderr(`${error.message}\n`);
|
|
98
|
+
return error.exitCode;
|
|
99
|
+
}
|
|
100
|
+
if (isCommanderExit(error)) {
|
|
101
|
+
return error.exitCode;
|
|
102
|
+
}
|
|
103
|
+
io.stderr(`${error.message}\n`);
|
|
104
|
+
return 2;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
function parseFormat(value) {
|
|
108
|
+
if (value === "json" || value === "markdown") {
|
|
109
|
+
return value;
|
|
110
|
+
}
|
|
111
|
+
throw new InvalidArgumentError('format must be "json" or "markdown".');
|
|
112
|
+
}
|
|
113
|
+
function parseMode(value) {
|
|
114
|
+
if (value === "advisory" || value === "strict") {
|
|
115
|
+
return value;
|
|
116
|
+
}
|
|
117
|
+
throw new InvalidArgumentError('mode must be "advisory" or "strict".');
|
|
118
|
+
}
|
|
119
|
+
function collect(value, previous) {
|
|
120
|
+
previous.push(value);
|
|
121
|
+
return previous;
|
|
122
|
+
}
|
|
123
|
+
function isCommanderExit(error) {
|
|
124
|
+
return Boolean(error && typeof error === "object" && "exitCode" in error);
|
|
125
|
+
}
|
package/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
package/dist/errors.d.ts
ADDED
package/dist/errors.js
ADDED
package/dist/git.d.ts
ADDED
package/dist/git.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
import { normalizePath } from "./paths.js";
|
|
4
|
+
const execFileAsync = promisify(execFile);
|
|
5
|
+
export async function getChangedFiles(options) {
|
|
6
|
+
if (options.explicitFiles && options.explicitFiles.length > 0) {
|
|
7
|
+
return uniqueNormalized(options.explicitFiles);
|
|
8
|
+
}
|
|
9
|
+
if (options.baseRef && options.headRef) {
|
|
10
|
+
const { stdout } = await execFileAsync("git", [
|
|
11
|
+
"diff",
|
|
12
|
+
"--name-only",
|
|
13
|
+
"--diff-filter=ACMRTUXB",
|
|
14
|
+
`${options.baseRef}...${options.headRef}`,
|
|
15
|
+
], {
|
|
16
|
+
cwd: options.cwd,
|
|
17
|
+
});
|
|
18
|
+
return lines(stdout);
|
|
19
|
+
}
|
|
20
|
+
const staged = await gitLines(options.cwd, [
|
|
21
|
+
"diff",
|
|
22
|
+
"--name-only",
|
|
23
|
+
"--cached",
|
|
24
|
+
"--diff-filter=ACMRTUXB",
|
|
25
|
+
]);
|
|
26
|
+
const unstaged = await gitLines(options.cwd, [
|
|
27
|
+
"diff",
|
|
28
|
+
"--name-only",
|
|
29
|
+
"--diff-filter=ACMRTUXB",
|
|
30
|
+
]);
|
|
31
|
+
const untracked = await gitLines(options.cwd, [
|
|
32
|
+
"ls-files",
|
|
33
|
+
"--others",
|
|
34
|
+
"--exclude-standard",
|
|
35
|
+
]);
|
|
36
|
+
return uniqueNormalized([...staged, ...unstaged, ...untracked]);
|
|
37
|
+
}
|
|
38
|
+
async function gitLines(cwd, args) {
|
|
39
|
+
const { stdout } = await execFileAsync("git", args, { cwd });
|
|
40
|
+
return lines(stdout);
|
|
41
|
+
}
|
|
42
|
+
function lines(text) {
|
|
43
|
+
return uniqueNormalized(text.split(/\r?\n/).filter(Boolean));
|
|
44
|
+
}
|
|
45
|
+
function uniqueNormalized(files) {
|
|
46
|
+
return [...new Set(files.map(normalizePath))].sort();
|
|
47
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { SpecGovConfig } from "./types.js";
|
|
2
|
+
export declare const DEFAULT_CONFIG_PATH = ".specgov.yml";
|
|
3
|
+
export declare const DEFAULT_CONFIG_TEMPLATE = "version: 1\nmode: advisory\n\nartifacts:\n - path: \"docs/**/*.md\"\n kind: documentation\n owner: docs\n - path: \"adr/**/*.md\"\n kind: decision\n owner: architecture\n - path: \".specs/**/*.md\"\n kind: specification\n owner: engineering\n\nmappings:\n - code: \"src/**\"\n specs:\n - \"docs/**\"\n - \"adr/**\"\n - \".specs/**\"\n\nrules:\n require_spec_impact_for_code_changes: true\n require_lifecycle_status: false\n require_owner_for_active_specs: false\n stale_after_days: 180\n\nignore:\n - \"node_modules/**\"\n - \"dist/**\"\n - \".git/**\"\n";
|
|
4
|
+
export declare function loadConfig(cwd: string, configPath?: string): Promise<SpecGovConfig>;
|
|
5
|
+
export declare function writeDefaultConfig(cwd: string, configPath?: string, force?: boolean): Promise<void>;
|
|
6
|
+
export declare function normalizeConfig(input: unknown, source?: string): SpecGovConfig;
|