@warpgogol/forge 2.8.0 → 2.8.2
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/AGENTS.md +1 -1
- package/README.md +23 -13
- package/README.uk.md +19 -12
- package/dist/os/session/handlers/validate.d.ts.map +1 -1
- package/dist/os/session/handlers/validate.js +23 -1
- package/dist/os/session/handlers/validate.js.map +1 -1
- package/dist/os/session/types.d.ts +31 -1
- package/dist/os/session/types.d.ts.map +1 -1
- package/dist/os/session/types.js +7 -0
- package/dist/os/session/types.js.map +1 -1
- package/os/adr/adr.module.ts +158 -0
- package/os/adr/frontmatter-io.ts +80 -0
- package/os/adr/handlers/archive.ts +244 -0
- package/os/adr/handlers/implement-stamp.ts +331 -0
- package/os/adr/handlers/list-create.ts +207 -0
- package/os/adr/handlers/validate.test.ts +232 -0
- package/os/adr/handlers/validate.ts +429 -0
- package/os/adr/index.ts +33 -0
- package/os/adr/types.ts +141 -0
- package/os/audit/audit.module.ts +48 -0
- package/os/audit/frontmatter-io.ts +84 -0
- package/os/audit/handlers/archive.ts +239 -0
- package/os/audit/index.ts +23 -0
- package/os/audit/types.ts +29 -0
- package/os/compass/compass.module.ts +186 -0
- package/os/compass/handlers/compass-audit-handler.ts +382 -0
- package/os/compass/handlers/compass-change-summary-handler.ts +272 -0
- package/os/compass/handlers/compass-inventory-handler.ts +294 -0
- package/os/compass/handlers/compass-inventory.ts +521 -0
- package/os/compass/handlers/git-revision.ts +137 -0
- package/os/compass/handlers/resolve-scan-root-workpiece.test.ts +65 -0
- package/os/compass/handlers/resolve-scan-root.ts +79 -0
- package/os/compass/index.ts +33 -0
- package/os/core/core.module.ts +948 -0
- package/os/core/handlers/assets-check.ts +122 -0
- package/os/core/handlers/assets-helpers.ts +174 -0
- package/os/core/handlers/assets-list.ts +99 -0
- package/os/core/handlers/build.ts +155 -0
- package/os/core/handlers/determinism-check.ts +354 -0
- package/os/core/handlers/dev.ts +127 -0
- package/os/core/handlers/invariant-engine.test.ts +681 -0
- package/os/core/handlers/knowledge-compact.ts +248 -0
- package/os/core/handlers/lifecycle-handlers.test.ts +524 -0
- package/os/core/handlers/note-frontmatter-validate.test.ts +93 -0
- package/os/core/handlers/note-link-validate.test.ts +117 -0
- package/os/core/handlers/note-orphan-detect.test.ts +94 -0
- package/os/core/handlers/package-health.test.ts +226 -0
- package/os/core/handlers/package-health.ts +231 -0
- package/os/core/handlers/pinned-check.ts +200 -0
- package/os/core/handlers/pinned-init.ts +333 -0
- package/os/core/handlers/pinned-types.ts +50 -0
- package/os/core/handlers/pinned-validate.ts +301 -0
- package/os/core/handlers/profile-resolve.ts +92 -0
- package/os/core/handlers/release-prepare.ts +294 -0
- package/os/core/handlers/release-publish.ts +211 -0
- package/os/core/handlers/validate.ts +262 -0
- package/os/core/handlers/workspace-deps.ts +55 -0
- package/os/core/index.ts +13 -0
- package/os/exploration/exploration.module.ts +90 -0
- package/os/exploration/frontmatter-io.ts +77 -0
- package/os/exploration/handlers/archive.ts +159 -0
- package/os/exploration/handlers/list.ts +77 -0
- package/os/exploration/handlers/show.ts +107 -0
- package/os/exploration/index.ts +25 -0
- package/os/exploration/types.ts +65 -0
- package/os/mission/handlers/archive.test.ts +391 -0
- package/os/mission/handlers/archive.ts +453 -0
- package/os/mission/index.ts +1 -0
- package/os/mission/mission.module.ts +48 -0
- package/os/mission/types.ts +41 -0
- package/os/naming/index.ts +14 -0
- package/os/naming/naming-convention.test.ts +163 -0
- package/os/naming/naming-convention.ts +378 -0
- package/os/naming/naming.module.ts +36 -0
- package/os/notes/index.ts +10 -0
- package/os/notes/notes.module.ts +109 -0
- package/os/plan/frontmatter-io.ts +82 -0
- package/os/plan/handlers/archive.ts +238 -0
- package/os/plan/index.ts +23 -0
- package/os/plan/plan.module.ts +48 -0
- package/os/plan/types.ts +29 -0
- package/os/program/discovery.ts +294 -0
- package/os/program/handlers/complete.ts +419 -0
- package/os/program/handlers/lease.ts +433 -0
- package/os/program/handlers/seal.ts +332 -0
- package/os/program/handlers/validate.ts +213 -0
- package/os/program/lease.ts +156 -0
- package/os/program/program.module.ts +212 -0
- package/os/program/schemas.ts +218 -0
- package/os/program/state.ts +264 -0
- package/os/rfc/acceptance.ts +344 -0
- package/os/rfc/decision-log.ts +310 -0
- package/os/rfc/dna-trace.ts +280 -0
- package/os/rfc/frontmatter-io.test.ts +132 -0
- package/os/rfc/frontmatter-io.ts +130 -0
- package/os/rfc/handlers/archive.ts +300 -0
- package/os/rfc/handlers/check.ts +238 -0
- package/os/rfc/handlers/implement-stamp.ts +515 -0
- package/os/rfc/handlers/index-graph.ts +237 -0
- package/os/rfc/handlers/lifecycle.test.ts +127 -0
- package/os/rfc/handlers/lifecycle.ts +249 -0
- package/os/rfc/handlers/list-create.ts +296 -0
- package/os/rfc/handlers/pipeline-status.ts +173 -0
- package/os/rfc/handlers/shared.ts +121 -0
- package/os/rfc/handlers/supersede-propose.ts +241 -0
- package/os/rfc/handlers/validate-rules.test.ts +891 -0
- package/os/rfc/handlers/validate-rules.ts +984 -0
- package/os/rfc/handlers/validate.ts +166 -0
- package/os/rfc/handlers.ts +19 -0
- package/os/rfc/index.ts +97 -0
- package/os/rfc/rfc.module.ts +416 -0
- package/os/rfc/types.ts +596 -0
- package/os/rfc/verification-evidence.ts +244 -0
- package/os/session/atif-parser.ts +166 -0
- package/os/session/frontmatter-io.ts +163 -0
- package/os/session/handlers/archive.ts +288 -0
- package/os/session/handlers/list.ts +174 -0
- package/os/session/handlers/save.ts +348 -0
- package/os/session/handlers/validate.ts +250 -0
- package/os/session/index.ts +50 -0
- package/os/session/session.module.ts +126 -0
- package/os/session/types.ts +203 -0
- package/os/spec/live-spec-list-show-validate.test.ts +257 -0
- package/os/spec/live-spec-list.ts +78 -0
- package/os/spec/live-spec-merge.test.ts +261 -0
- package/os/spec/live-spec-merge.ts +413 -0
- package/os/spec/live-spec-show.ts +117 -0
- package/os/spec/live-spec-types.ts +93 -0
- package/os/spec/live-spec-validate.ts +170 -0
- package/os/spec/spec-materialize.ts +390 -0
- package/os/spec/spec-schema.ts +174 -0
- package/os/spec/spec-status.ts +284 -0
- package/os/spec/spec-validate.ts +513 -0
- package/os/spec/spec.module.ts +135 -0
- package/os/werkstatt/handlers/lock.ts +168 -0
- package/os/werkstatt/handlers/schema.ts +47 -0
- package/os/werkstatt/handlers/werkstatt-lock-recover.ts +175 -0
- package/os/werkstatt/handlers/werkstatt-lock-status.ts +73 -0
- package/os/werkstatt/handlers/werkstatt-operation-validate.ts +104 -0
- package/os/werkstatt/index.ts +39 -0
- package/os/werkstatt/werkstatt.module.ts +62 -0
- package/os/workflow/handlers.ts +304 -0
- package/os/workflow/index.ts +28 -0
- package/os/workflow/types.ts +99 -0
- package/os/workflow/workflow.module.ts +54 -0
- package/package.json +3 -3
- package/profiles/godot-csharp.yaml +4 -4
- package/profiles/knowledge-typescript-turborepo.yaml +110 -0
- package/profiles/phaser-turborepo.yaml +5 -5
- package/skills/_shared/fo-session-summary.md +132 -16
- package/skills/fo/fo-add-tests/pbt-guide.md +13 -13
- package/skills/fo/fo-doc-audit/SKILL.md +6 -6
- package/skills/fo/fo-handoff/SKILL.md +5 -0
- package/skills/fo/fo-idea/SKILL.md +1 -1
- package/skills/fo/fo-review/SKILL.md +1 -1
- package/skills/fo/fo-session-retro/SKILL.md +8 -0
- package/skills/fo/fo-session-save/SKILL.md +8 -0
- package/src/cli-output.ts +59 -0
- package/src/config/__tests__/resolve-terminology.test.ts +45 -0
- package/src/config/forge-config.ts +524 -0
- package/src/forge-module.ts +39 -0
- package/src/index.ts +172 -0
- package/src/knowledge/__tests__/promote.test.ts +219 -0
- package/src/knowledge/__tests__/serialize.test.ts +192 -0
- package/src/knowledge/budgets.ts +202 -0
- package/src/knowledge/compact.ts +405 -0
- package/src/knowledge/index.ts +53 -0
- package/src/knowledge/parse.ts +227 -0
- package/src/knowledge/promote.ts +156 -0
- package/src/knowledge/schema.ts +112 -0
- package/src/knowledge/serialize.ts +72 -0
- package/src/migration-adapters/__tests__/ignored-files.test.ts +51 -0
- package/src/migration-adapters/__tests__/registry.test.ts +131 -0
- package/src/migration-adapters/__tests__/types.test.ts +86 -0
- package/src/migration-adapters/git-utils.ts +71 -0
- package/src/migration-adapters/ignored-files.ts +221 -0
- package/src/migration-adapters/index.ts +21 -0
- package/src/migration-adapters/node-typescript-pnpm/index.ts +189 -0
- package/src/migration-adapters/phaser-pnpm/index.ts +189 -0
- package/src/migration-adapters/registry.ts +61 -0
- package/src/migration-adapters/types.ts +68 -0
- package/src/onboarding/__tests__/workspace-discovery.test.ts +134 -0
- package/src/onboarding/agents-generate.ts +443 -0
- package/src/onboarding/create.ts +427 -0
- package/src/onboarding/doctor.ts +1297 -0
- package/src/onboarding/init.ts +337 -0
- package/src/onboarding/invariant-engine.ts +448 -0
- package/src/onboarding/memory-scaffold.ts +189 -0
- package/src/onboarding/nested-agents-generate.ts +93 -0
- package/src/onboarding/nested-agents-templates.ts +233 -0
- package/src/onboarding/profile-validate.ts +139 -0
- package/src/onboarding/scaffold-project.ts +299 -0
- package/src/onboarding/scaffold.ts +143 -0
- package/src/onboarding/upgrade.ts +558 -0
- package/src/onboarding/workspace-discovery.ts +167 -0
- package/src/profiles/__tests__/profile-schema.test.ts +228 -0
- package/src/profiles/__tests__/stack-profile.test.ts +190 -0
- package/src/profiles/__tests__/terminology-utils.test.ts +71 -0
- package/src/profiles/profile-schema.ts +350 -0
- package/src/profiles/stack-profile.ts +188 -0
- package/src/profiles/terminology-utils.ts +36 -0
- package/src/registry.ts +185 -0
- package/src/skill-schema.ts +37 -0
- package/src/tests/acceptance-criteria.test.ts +99 -0
- package/src/tests/adr-implement-stamp.test.ts +368 -0
- package/src/tests/agents-generate-domain.test.ts +317 -0
- package/src/tests/agents-generate.test.ts +458 -0
- package/src/tests/bindings-schema.test.ts +314 -0
- package/src/tests/budgets.test.ts +213 -0
- package/src/tests/cli-output.test.ts +196 -0
- package/src/tests/compact.test.ts +796 -0
- package/src/tests/create.test.ts +289 -0
- package/src/tests/doctor-autonomy.test.ts +98 -0
- package/src/tests/doctor-bindings.test.ts +286 -0
- package/src/tests/doctor-domain.test.ts +113 -0
- package/src/tests/exploration-handlers.test.ts +256 -0
- package/src/tests/fixtures/agents-generate-business-before.txt +288 -0
- package/src/tests/forge-config.test.ts +420 -0
- package/src/tests/fs-atomic.test.ts +96 -0
- package/src/tests/fs.test.ts +103 -0
- package/src/tests/generated-marker.test.ts +161 -0
- package/src/tests/hash.test.ts +37 -0
- package/src/tests/implement-stamp.test.ts +505 -0
- package/src/tests/init-bindings.test.ts +183 -0
- package/src/tests/knowledge-parse.test.ts +307 -0
- package/src/tests/knowledge-pbt.test.ts +164 -0
- package/src/tests/memory-scaffold.test.ts +192 -0
- package/src/tests/migration-adapters.test.ts +480 -0
- package/src/tests/package-files.test.ts +54 -0
- package/src/tests/pinned-check.test.ts +194 -0
- package/src/tests/pinned-init.test.ts +126 -0
- package/src/tests/profile-schema.test.ts +194 -0
- package/src/tests/profile-validate.test.ts +120 -0
- package/src/tests/program-lease.test.ts +245 -0
- package/src/tests/program-paths-extended.test.ts +117 -0
- package/src/tests/program-paths.test.ts +98 -0
- package/src/tests/program-property.test.ts +139 -0
- package/src/tests/program-schemas.test.ts +445 -0
- package/src/tests/program-spec-node.test.ts +250 -0
- package/src/tests/promote.test.ts +481 -0
- package/src/tests/registry.test.ts +73 -0
- package/src/tests/scaffold-project.test.ts +150 -0
- package/src/tests/session-handlers.test.ts +503 -0
- package/src/tests/session-pbt.test.ts +190 -0
- package/src/tests/skill-schema.test.ts +146 -0
- package/src/tests/skill-validate-knowledge.test.ts +26 -0
- package/src/tests/skill-validate.test.ts +254 -0
- package/src/tests/stack-profile.test.ts +221 -0
- package/src/tests/string-utils.test.ts +42 -0
- package/src/tests/upgrade.test.ts +428 -0
- package/src/tests/werkstatt-lock.test.ts +343 -0
- package/src/tests/workspace-discovery-domain.test.ts +112 -0
- package/src/tests/workspace-discovery.test.ts +135 -0
- package/src/types.ts +221 -0
- package/src/utils/__tests__/fs-idempotent.test.ts +73 -0
- package/src/utils/__tests__/fs-trash.test.ts +45 -0
- package/src/utils/fs-atomic.ts +93 -0
- package/src/utils/fs-idempotent.ts +41 -0
- package/src/utils/fs-trash-sync.ts +37 -0
- package/src/utils/fs-trash.ts +24 -0
- package/src/utils/fs.ts +74 -0
- package/src/utils/generated-marker.ts +166 -0
- package/src/utils/hash.ts +19 -0
- package/src/utils/index.ts +29 -0
- package/src/utils/string-utils.ts +19 -0
- package/src/validators/__tests__/note-orphan-detect.test.ts +94 -0
- package/src/validators/note-frontmatter-validate.ts +142 -0
- package/src/validators/note-link-validate.ts +145 -0
- package/src/validators/note-orphan-detect.ts +146 -0
- package/src/validators/port-validate.ts +97 -0
- package/src/validators/skill-validate.ts +831 -0
|
@@ -0,0 +1,984 @@
|
|
|
1
|
+
/*
|
|
2
|
+
<MODULE_CONTRACT>
|
|
3
|
+
<purpose>Per-RFC validation rules (V-01..V-34, RFC-DIR-01) extracted from the validate handler for modularity.</purpose>
|
|
4
|
+
<non-goals>
|
|
5
|
+
<item>Do not introduce app-specific runtime composition or deployment behavior into this reusable package source file.</item>
|
|
6
|
+
</non-goals>
|
|
7
|
+
</MODULE_CONTRACT>
|
|
8
|
+
<CHANGE_SUMMARY>
|
|
9
|
+
<item>RFC-0303 Phase 3: extracted from validate.ts as part of the handler split.</item>
|
|
10
|
+
<item>RFC-0722: add RFC-DIR-01 directory structure warning rule for unsanctioned subdirectories.</item>
|
|
11
|
+
<item>RFC-0755: add V-RFC-33 frontmatter YAML parseability check (checkFrontmatterYamlParse helper).</item>
|
|
12
|
+
<item>RFC-0795: add V-33 dependsOn referential integrity/self-dependency/rejected-dependency and V-34 batch slug format rules.</item>
|
|
13
|
+
</CHANGE_SUMMARY>
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import path from "node:path";
|
|
17
|
+
import { readFile } from "node:fs/promises";
|
|
18
|
+
import { execFile } from "node:child_process";
|
|
19
|
+
|
|
20
|
+
import { parse as yamlParse } from "yaml";
|
|
21
|
+
import { validateAcceptanceShape } from "../acceptance.ts";
|
|
22
|
+
import type { ParsedRfc } from "../frontmatter-io.ts";
|
|
23
|
+
import type { ReadAndParseRfcResult } from "../frontmatter-io.ts";
|
|
24
|
+
import type { RfcStatus, RfcKind, RfcScope, Marker } from "../types.ts";
|
|
25
|
+
import {
|
|
26
|
+
RFC_DIR,
|
|
27
|
+
RFC_ID_PATTERN,
|
|
28
|
+
RFC_STATUSES,
|
|
29
|
+
RFC_KINDS,
|
|
30
|
+
RFC_SCOPES,
|
|
31
|
+
RFC_FULL_REQUIRED_SECTIONS,
|
|
32
|
+
RFC_METADATA_CUTOFF,
|
|
33
|
+
RFC_VERSION_BUMP_CUTOFF,
|
|
34
|
+
} from "../types.ts";
|
|
35
|
+
import { DNA_DOCS, AP_DOCS } from "./shared.ts";
|
|
36
|
+
|
|
37
|
+
const DNA_DOC = DNA_DOCS[0]!;
|
|
38
|
+
const AP_DOC = AP_DOCS[0]!;
|
|
39
|
+
|
|
40
|
+
// RFC-0335: reviewer identity validation constants
|
|
41
|
+
const REVIEWER_IDENTITY_PATTERN = /^(human|agent):[a-z0-9][a-z0-9-]*$/;
|
|
42
|
+
const DECIDED_STATUSES: readonly RfcStatus[] = [
|
|
43
|
+
"accepted",
|
|
44
|
+
"implemented",
|
|
45
|
+
"rejected",
|
|
46
|
+
"superseded",
|
|
47
|
+
];
|
|
48
|
+
|
|
49
|
+
export type AddViolationFn = (
|
|
50
|
+
rfcId: string,
|
|
51
|
+
file: string,
|
|
52
|
+
rule: string,
|
|
53
|
+
message: string,
|
|
54
|
+
severity?: "error" | "warning",
|
|
55
|
+
) => void;
|
|
56
|
+
|
|
57
|
+
// ─── RFC-0476: reusable acceptance-criteria evaluation ──────────────────────
|
|
58
|
+
// Extracted from V-26/V-27 inline logic so the stamp command and validation
|
|
59
|
+
// share a single criterion-evaluation source.
|
|
60
|
+
|
|
61
|
+
export interface AcceptanceCriteriaEvaluation {
|
|
62
|
+
totalChecked: number;
|
|
63
|
+
totalUnchecked: number;
|
|
64
|
+
uncheckedLines: string[];
|
|
65
|
+
checkedWithoutEvidence: string[];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Extract the raw acceptance-criteria section text from an RFC body.
|
|
70
|
+
* Returns `undefined` when the section is absent.
|
|
71
|
+
*/
|
|
72
|
+
export function extractAcceptanceCriteriaSection(body: string): string | undefined {
|
|
73
|
+
const match = body.match(/## Acceptance criteria\s*\n([\s\S]*?)(?=\n## |\n*$)/);
|
|
74
|
+
return match?.[1];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Evaluate acceptance criteria checkboxes and inline evidence annotations.
|
|
79
|
+
* Pure function — no I/O, no side effects. Used by both V-26/V-27 validation
|
|
80
|
+
* and the `rfc.implement.stamp` precondition checks (RFC-0476).
|
|
81
|
+
*/
|
|
82
|
+
export function evaluateAcceptanceCriteria(body: string): AcceptanceCriteriaEvaluation {
|
|
83
|
+
const section = extractAcceptanceCriteriaSection(body);
|
|
84
|
+
if (!section) {
|
|
85
|
+
return {
|
|
86
|
+
totalChecked: 0,
|
|
87
|
+
totalUnchecked: 0,
|
|
88
|
+
uncheckedLines: [],
|
|
89
|
+
checkedWithoutEvidence: [],
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const uncheckedLines = section
|
|
94
|
+
.split("\n")
|
|
95
|
+
.filter((line) => /^- \[ \]/.test(line))
|
|
96
|
+
.map((line) => line.trim());
|
|
97
|
+
const totalUnchecked = uncheckedLines.length;
|
|
98
|
+
|
|
99
|
+
const checkedLines = section.split("\n").filter((line) => /^- \[x\]/.test(line));
|
|
100
|
+
const checkedWithoutEvidence: string[] = [];
|
|
101
|
+
for (const line of checkedLines) {
|
|
102
|
+
if (!/\(evidence:\s*.+\)/.test(line)) {
|
|
103
|
+
checkedWithoutEvidence.push(line.trim());
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
totalChecked: checkedLines.length,
|
|
109
|
+
totalUnchecked,
|
|
110
|
+
uncheckedLines,
|
|
111
|
+
checkedWithoutEvidence,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ─── RFC-0625: V-32 implementation commit drift detection ──────────────────
|
|
116
|
+
|
|
117
|
+
function execGitLog(workspaceRoot: string, args: string[]): Promise<string> {
|
|
118
|
+
return new Promise((resolve) => {
|
|
119
|
+
execFile("git", args, { cwd: workspaceRoot, timeout: 10000 }, (err, stdout) => {
|
|
120
|
+
if (err) {
|
|
121
|
+
resolve("");
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
resolve(stdout.trim());
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async function checkImplementationCommitDrift(
|
|
130
|
+
workspaceRoot: string,
|
|
131
|
+
rfcId: string,
|
|
132
|
+
createdAt: string,
|
|
133
|
+
currentStatus: string,
|
|
134
|
+
): Promise<{ found: boolean; commitCount: number }> {
|
|
135
|
+
if (currentStatus === "implemented") {
|
|
136
|
+
return { found: false, commitCount: 0 };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const datePattern = /^\d{4}-\d{2}-\d{2}$/;
|
|
140
|
+
if (!datePattern.test(createdAt)) {
|
|
141
|
+
return { found: false, commitCount: 0 };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const log = await execGitLog(workspaceRoot, ["log", `--since=${createdAt}`, "--oneline"]);
|
|
145
|
+
|
|
146
|
+
if (!log) {
|
|
147
|
+
return { found: false, commitCount: 0 };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const pattern = new RegExp(`implement:\\s+${rfcId}\\b`, "i");
|
|
151
|
+
const matchingLines = log.split("\n").filter((line) => pattern.test(line.trim()));
|
|
152
|
+
|
|
153
|
+
return { found: matchingLines.length > 0, commitCount: matchingLines.length };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// ─── RFC-0709: V-NC-01 NEEDS CLARIFICATION marker detection ────────────────
|
|
157
|
+
|
|
158
|
+
const NC_MARKER_CUTOFF = "2026-08-06";
|
|
159
|
+
const NC_MARKER_PATTERN = /^>\s*NEEDS CLARIFICATION:\s*(.+)$/;
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Collect NEEDS CLARIFICATION markers from an RFC body.
|
|
163
|
+
* Skips lines inside fenced code blocks. Pure function — no I/O.
|
|
164
|
+
*/
|
|
165
|
+
export function collectMarkers(body: string, status: string, createdAt: string): Marker[] {
|
|
166
|
+
if (createdAt < NC_MARKER_CUTOFF) return [];
|
|
167
|
+
|
|
168
|
+
const severity: Marker["severity"] = status === "draft" ? "warn" : "error";
|
|
169
|
+
const markers: Marker[] = [];
|
|
170
|
+
const lines = body.split("\n");
|
|
171
|
+
let inCodeBlock = false;
|
|
172
|
+
|
|
173
|
+
for (let i = 0; i < lines.length; i++) {
|
|
174
|
+
const line = lines[i]!;
|
|
175
|
+
if (/^```/.test(line.trim())) {
|
|
176
|
+
inCodeBlock = !inCodeBlock;
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
if (inCodeBlock) continue;
|
|
180
|
+
const match = line.match(NC_MARKER_PATTERN);
|
|
181
|
+
if (match) {
|
|
182
|
+
markers.push({
|
|
183
|
+
line: i + 1,
|
|
184
|
+
text: match[1]!.trim(),
|
|
185
|
+
severity,
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return markers;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export async function validateSingleRfc(
|
|
194
|
+
fileName: string,
|
|
195
|
+
parsed: ParsedRfc,
|
|
196
|
+
allParsed: Map<string, { fileName: string; parsed: ParsedRfc }>,
|
|
197
|
+
seenIds: Map<string, string>,
|
|
198
|
+
validDnaIds: Set<number>,
|
|
199
|
+
validApIds: Set<number>,
|
|
200
|
+
knownKeys: Set<string>,
|
|
201
|
+
workspaceRoot: string,
|
|
202
|
+
addViolation: AddViolationFn,
|
|
203
|
+
seenFilenameNumbers?: Map<number, string>,
|
|
204
|
+
): Promise<Marker[]> {
|
|
205
|
+
const fm = parsed.frontmatter;
|
|
206
|
+
const body = parsed.body;
|
|
207
|
+
const relFile = path.join(RFC_DIR, fileName);
|
|
208
|
+
const rfcId = String(fm["id"] ?? "UNKNOWN");
|
|
209
|
+
const isArchived = fileName.startsWith("archive/");
|
|
210
|
+
|
|
211
|
+
// V-01: id format
|
|
212
|
+
if (!RFC_ID_PATTERN.test(rfcId)) {
|
|
213
|
+
addViolation(rfcId, relFile, "V-01", `id "${rfcId}" does not match format RFC-XXXX`);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// RFC-DIR-01: directory structure convention (RFC-0722)
|
|
217
|
+
// Warn when RFC files are found in subdirectories other than archive/ and verification/
|
|
218
|
+
const slashIdx = fileName.indexOf("/");
|
|
219
|
+
if (slashIdx > 0) {
|
|
220
|
+
const subDir = fileName.slice(0, slashIdx);
|
|
221
|
+
if (subDir !== "archive" && subDir !== "verification") {
|
|
222
|
+
addViolation(
|
|
223
|
+
rfcId,
|
|
224
|
+
relFile,
|
|
225
|
+
"RFC-DIR-01",
|
|
226
|
+
`${relFile} is in an unsanctioned subdirectory. Only archive/ and verification/ are allowed. Move the file to docs/rfcs/ root or write an ADR to formalize the subdirectory.`,
|
|
227
|
+
"warning",
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// V-02: id uniqueness
|
|
233
|
+
const prevFile = seenIds.get(rfcId);
|
|
234
|
+
if (prevFile) {
|
|
235
|
+
addViolation(
|
|
236
|
+
rfcId,
|
|
237
|
+
relFile,
|
|
238
|
+
"V-02",
|
|
239
|
+
`Duplicate id "${rfcId}" — also in ${prevFile}. ` +
|
|
240
|
+
`This happens when an RFC is created by scanning only the top-level docs/rfcs/ directory, missing archived RFCs under docs/rfcs/archive/. ` +
|
|
241
|
+
`Fix: renumber this RFC by deleting it and re-running rfc.create (or spec.materialize) which scans the full tree recursively.`,
|
|
242
|
+
);
|
|
243
|
+
} else {
|
|
244
|
+
seenIds.set(rfcId, relFile);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// V-03: valid status
|
|
248
|
+
const status = String(fm["status"] ?? "");
|
|
249
|
+
if (!RFC_STATUSES.includes(status as RfcStatus)) {
|
|
250
|
+
addViolation(
|
|
251
|
+
rfcId,
|
|
252
|
+
relFile,
|
|
253
|
+
"V-03",
|
|
254
|
+
`Invalid status "${status}". Must be one of: ${RFC_STATUSES.join(", ")}`,
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// V-04: valid kind
|
|
259
|
+
const kind = String(fm["kind"] ?? "");
|
|
260
|
+
if (!RFC_KINDS.includes(kind as RfcKind)) {
|
|
261
|
+
addViolation(
|
|
262
|
+
rfcId,
|
|
263
|
+
relFile,
|
|
264
|
+
"V-04",
|
|
265
|
+
`Invalid kind "${kind}". Must be one of: ${RFC_KINDS.join(", ")}`,
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// V-05: valid scope
|
|
270
|
+
const scope = String(fm["scope"] ?? "");
|
|
271
|
+
if (!RFC_SCOPES.includes(scope as RfcScope)) {
|
|
272
|
+
addViolation(
|
|
273
|
+
rfcId,
|
|
274
|
+
relFile,
|
|
275
|
+
"V-05",
|
|
276
|
+
`Invalid scope "${scope}". Must be one of: ${RFC_SCOPES.join(", ")}`,
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// V-06: owners non-empty
|
|
281
|
+
const owners = fm["owners"];
|
|
282
|
+
if (!Array.isArray(owners) || owners.length === 0) {
|
|
283
|
+
addViolation(rfcId, relFile, "V-06", "owners must be a non-empty array");
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// V-07: valid dates
|
|
287
|
+
const createdAt = String(fm["createdAt"] ?? "");
|
|
288
|
+
const updatedAt = String(fm["updatedAt"] ?? "");
|
|
289
|
+
const datePattern = /^\d{4}-\d{2}-\d{2}$/;
|
|
290
|
+
if (!datePattern.test(createdAt)) {
|
|
291
|
+
addViolation(
|
|
292
|
+
rfcId,
|
|
293
|
+
relFile,
|
|
294
|
+
"V-07",
|
|
295
|
+
`createdAt "${createdAt}" is not a valid ISO 8601 date (YYYY-MM-DD)`,
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
if (!datePattern.test(updatedAt)) {
|
|
299
|
+
addViolation(
|
|
300
|
+
rfcId,
|
|
301
|
+
relFile,
|
|
302
|
+
"V-07",
|
|
303
|
+
`updatedAt "${updatedAt}" is not a valid ISO 8601 date (YYYY-MM-DD)`,
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
const enhancedAt = fm["enhancedAt"] as string | undefined;
|
|
307
|
+
if (enhancedAt && !datePattern.test(enhancedAt)) {
|
|
308
|
+
addViolation(
|
|
309
|
+
rfcId,
|
|
310
|
+
relFile,
|
|
311
|
+
"V-07",
|
|
312
|
+
`enhancedAt "${enhancedAt}" is not a valid ISO 8601 date (YYYY-MM-DD)`,
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// V-08: implementedAt >= createdAt
|
|
317
|
+
const implementedAt = fm["implementedAt"] as string | undefined;
|
|
318
|
+
if (implementedAt && datePattern.test(implementedAt) && datePattern.test(createdAt)) {
|
|
319
|
+
if (implementedAt < createdAt) {
|
|
320
|
+
addViolation(
|
|
321
|
+
rfcId,
|
|
322
|
+
relFile,
|
|
323
|
+
"V-08",
|
|
324
|
+
`implementedAt (${implementedAt}) precedes createdAt (${createdAt})`,
|
|
325
|
+
);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// V-08b: enhancedAt >= createdAt
|
|
330
|
+
if (enhancedAt && datePattern.test(enhancedAt) && datePattern.test(createdAt)) {
|
|
331
|
+
if (enhancedAt < createdAt) {
|
|
332
|
+
addViolation(
|
|
333
|
+
rfcId,
|
|
334
|
+
relFile,
|
|
335
|
+
"V-08",
|
|
336
|
+
`enhancedAt (${enhancedAt}) precedes createdAt (${createdAt})`,
|
|
337
|
+
);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// V-09: closedAt >= createdAt
|
|
342
|
+
const closedAt = fm["closedAt"] as string | undefined;
|
|
343
|
+
if (closedAt && datePattern.test(closedAt) && datePattern.test(createdAt)) {
|
|
344
|
+
if (closedAt < createdAt) {
|
|
345
|
+
addViolation(
|
|
346
|
+
rfcId,
|
|
347
|
+
relFile,
|
|
348
|
+
"V-09",
|
|
349
|
+
`closedAt (${closedAt}) precedes createdAt (${createdAt})`,
|
|
350
|
+
);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// V-10: supersedes point to existing RFCs (+ V-12 forward back-link check)
|
|
355
|
+
const supersedes = fm["supersedes"];
|
|
356
|
+
if (Array.isArray(supersedes)) {
|
|
357
|
+
for (const ref of supersedes) {
|
|
358
|
+
const refStr = String(ref);
|
|
359
|
+
if (!refStr) continue;
|
|
360
|
+
if (!allParsed.has(refStr)) {
|
|
361
|
+
addViolation(
|
|
362
|
+
rfcId,
|
|
363
|
+
relFile,
|
|
364
|
+
"V-10",
|
|
365
|
+
`supersedes "${refStr}" does not match any existing RFC`,
|
|
366
|
+
);
|
|
367
|
+
} else {
|
|
368
|
+
const otherBy = String(allParsed.get(refStr)!.parsed.frontmatter["supersededBy"] ?? "");
|
|
369
|
+
if (otherBy !== rfcId) {
|
|
370
|
+
addViolation(
|
|
371
|
+
rfcId,
|
|
372
|
+
relFile,
|
|
373
|
+
"V-12",
|
|
374
|
+
`${rfcId}.supersedes includes ${refStr}, but ${refStr}.supersededBy is "${otherBy || "(empty)"}" (expected ${rfcId})`,
|
|
375
|
+
"warning",
|
|
376
|
+
);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// V-11: supersededBy points to existing RFC
|
|
383
|
+
// Skip for archived RFCs — superseding RFCs may have been themselves archived/removed
|
|
384
|
+
const supersededBy = fm["supersededBy"] as string | undefined;
|
|
385
|
+
if (supersededBy && !allParsed.has(supersededBy) && !isArchived) {
|
|
386
|
+
addViolation(
|
|
387
|
+
rfcId,
|
|
388
|
+
relFile,
|
|
389
|
+
"V-11",
|
|
390
|
+
`supersededBy "${supersededBy}" does not match any existing RFC`,
|
|
391
|
+
);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// V-12 (reverse): supersededBy target must list this RFC in its supersedes
|
|
395
|
+
if (supersededBy && allParsed.has(supersededBy)) {
|
|
396
|
+
const otherFm = allParsed.get(supersededBy)!.parsed.frontmatter;
|
|
397
|
+
const otherSupersedes = otherFm["supersedes"];
|
|
398
|
+
if (!Array.isArray(otherSupersedes) || !otherSupersedes.map(String).includes(rfcId)) {
|
|
399
|
+
addViolation(
|
|
400
|
+
rfcId,
|
|
401
|
+
relFile,
|
|
402
|
+
"V-12",
|
|
403
|
+
`${rfcId} is supersededBy ${supersededBy}, but ${supersededBy}.supersedes does not include ${rfcId}`,
|
|
404
|
+
"warning",
|
|
405
|
+
);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// V-17: strict supersession lifecycle
|
|
410
|
+
if (supersededBy && status !== "superseded") {
|
|
411
|
+
addViolation(
|
|
412
|
+
rfcId,
|
|
413
|
+
relFile,
|
|
414
|
+
"V-17",
|
|
415
|
+
`supersededBy is set (${supersededBy}) but status is "${status}" (expected "superseded")`,
|
|
416
|
+
"warning",
|
|
417
|
+
);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// V-13: required H2 sections (all RFCs use the full template)
|
|
421
|
+
const requiredSections = RFC_FULL_REQUIRED_SECTIONS;
|
|
422
|
+
const headings = [...body.matchAll(/^##\s+(.+)$/gm)].map((m) => m[1]!.trim());
|
|
423
|
+
|
|
424
|
+
for (const section of requiredSections) {
|
|
425
|
+
if (!headings.includes(section)) {
|
|
426
|
+
const severity = status === "draft" ? "warning" : "error";
|
|
427
|
+
// Skip error-severity V-13 for archived RFCs — template requirements evolved after archival
|
|
428
|
+
if (severity === "error" && isArchived) continue;
|
|
429
|
+
addViolation(rfcId, relFile, "V-13", `Missing required section "## ${section}"`, severity);
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// V-14: acceptance criteria must have at least 3 checklist items
|
|
434
|
+
const acceptanceMatch = body.match(/## Acceptance criteria\s*\n([\s\S]*?)(?=\n## |\n*$)/);
|
|
435
|
+
if (acceptanceMatch) {
|
|
436
|
+
const checklistItems = acceptanceMatch[1]!.match(/^- \[[ x]\]/gm);
|
|
437
|
+
const count = checklistItems?.length ?? 0;
|
|
438
|
+
if (count < 3) {
|
|
439
|
+
addViolation(
|
|
440
|
+
rfcId,
|
|
441
|
+
relFile,
|
|
442
|
+
"V-14",
|
|
443
|
+
`Acceptance criteria has ${count} checklist items; minimum is 3`,
|
|
444
|
+
status === "draft" ? "warning" : "error",
|
|
445
|
+
);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// V-26: implemented RFCs must have all acceptance criteria checked (RFC-0463)
|
|
450
|
+
// V-27: every checked criterion must carry inline (evidence: ...) annotation (RFC-0463)
|
|
451
|
+
// RFC-0476: both use the shared evaluateAcceptanceCriteria function.
|
|
452
|
+
if (acceptanceMatch) {
|
|
453
|
+
const criteriaEval = evaluateAcceptanceCriteria(body);
|
|
454
|
+
if (status === "implemented" && criteriaEval.totalUnchecked > 0 && !isArchived) {
|
|
455
|
+
addViolation(
|
|
456
|
+
rfcId,
|
|
457
|
+
relFile,
|
|
458
|
+
"V-26",
|
|
459
|
+
`status is "implemented" but ${criteriaEval.totalUnchecked} acceptance criteria are unchecked. ` +
|
|
460
|
+
`Complete the work or split deferred criteria into a follow-up RFC via supersede.`,
|
|
461
|
+
);
|
|
462
|
+
}
|
|
463
|
+
for (const line of criteriaEval.checkedWithoutEvidence) {
|
|
464
|
+
addViolation(
|
|
465
|
+
rfcId,
|
|
466
|
+
relFile,
|
|
467
|
+
"V-27",
|
|
468
|
+
`checked acceptance criterion lacks inline (evidence: ...) annotation: "${line}"`,
|
|
469
|
+
);
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// V-15: title consistency
|
|
474
|
+
const titleStr = String(fm["title"] ?? "");
|
|
475
|
+
const headingMatch = body.match(/^#\s+(?:RFC-\d{4}:\s*)?(.+)$/m);
|
|
476
|
+
if (headingMatch && titleStr) {
|
|
477
|
+
const headingTitle = headingMatch[1]!.trim();
|
|
478
|
+
if (headingTitle !== titleStr) {
|
|
479
|
+
addViolation(
|
|
480
|
+
rfcId,
|
|
481
|
+
relFile,
|
|
482
|
+
"V-15",
|
|
483
|
+
`Frontmatter title "${titleStr}" does not match body heading "${headingTitle}"`,
|
|
484
|
+
"warning",
|
|
485
|
+
);
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// V-16: status <-> date coupling
|
|
490
|
+
const implementedAtStr = String(fm["implementedAt"] ?? "");
|
|
491
|
+
const closedAtStr = String(fm["closedAt"] ?? "");
|
|
492
|
+
const isTerminalStatus = status === "superseded" || status === "rejected";
|
|
493
|
+
if (status === "implemented" && !implementedAtStr) {
|
|
494
|
+
addViolation(
|
|
495
|
+
rfcId,
|
|
496
|
+
relFile,
|
|
497
|
+
"V-16",
|
|
498
|
+
`status is "implemented" but implementedAt is empty`,
|
|
499
|
+
"error",
|
|
500
|
+
);
|
|
501
|
+
}
|
|
502
|
+
if ((status === "accepted" || status === "draft") && implementedAtStr) {
|
|
503
|
+
addViolation(
|
|
504
|
+
rfcId,
|
|
505
|
+
relFile,
|
|
506
|
+
"V-16",
|
|
507
|
+
`status is "${status}" but implementedAt is set (${implementedAtStr})`,
|
|
508
|
+
"error",
|
|
509
|
+
);
|
|
510
|
+
}
|
|
511
|
+
if (isTerminalStatus && !closedAtStr) {
|
|
512
|
+
addViolation(rfcId, relFile, "V-16", `status is "${status}" but closedAt is empty`, "warning");
|
|
513
|
+
}
|
|
514
|
+
if (!isTerminalStatus && closedAtStr) {
|
|
515
|
+
addViolation(
|
|
516
|
+
rfcId,
|
|
517
|
+
relFile,
|
|
518
|
+
"V-16",
|
|
519
|
+
`closedAt is set (${closedAtStr}) but status "${status}" is not terminal (superseded/rejected)`,
|
|
520
|
+
"warning",
|
|
521
|
+
);
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
// V-18: referential integrity of related[]
|
|
525
|
+
const related = fm["related"];
|
|
526
|
+
if (Array.isArray(related)) {
|
|
527
|
+
for (const ref of related) {
|
|
528
|
+
const refStr = String(ref).trim();
|
|
529
|
+
if (!refStr) continue;
|
|
530
|
+
if (/^RFC-\d{4}$/.test(refStr)) {
|
|
531
|
+
if (!allParsed.has(refStr)) {
|
|
532
|
+
addViolation(
|
|
533
|
+
rfcId,
|
|
534
|
+
relFile,
|
|
535
|
+
"V-18",
|
|
536
|
+
`related "${refStr}" does not match any existing RFC`,
|
|
537
|
+
"warning",
|
|
538
|
+
);
|
|
539
|
+
}
|
|
540
|
+
} else if (/^DNA-\d+$/i.test(refStr) && validDnaIds.size > 0) {
|
|
541
|
+
if (!validDnaIds.has(parseInt(refStr.slice(4), 10))) {
|
|
542
|
+
addViolation(
|
|
543
|
+
rfcId,
|
|
544
|
+
relFile,
|
|
545
|
+
"V-18",
|
|
546
|
+
`related "${refStr}" is not defined in ${DNA_DOC}`,
|
|
547
|
+
"warning",
|
|
548
|
+
);
|
|
549
|
+
}
|
|
550
|
+
} else if (/^AP-\d+$/i.test(refStr) && validApIds.size > 0) {
|
|
551
|
+
if (!validApIds.has(parseInt(refStr.slice(3), 10))) {
|
|
552
|
+
addViolation(
|
|
553
|
+
rfcId,
|
|
554
|
+
relFile,
|
|
555
|
+
"V-18",
|
|
556
|
+
`related "${refStr}" is not defined in ${AP_DOC}`,
|
|
557
|
+
"warning",
|
|
558
|
+
);
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
// V-19: amends/amendedBy referential + bidirectional integrity
|
|
565
|
+
const amends = fm["amends"];
|
|
566
|
+
if (Array.isArray(amends)) {
|
|
567
|
+
for (const ref of amends) {
|
|
568
|
+
const refStr = String(ref);
|
|
569
|
+
if (!refStr) continue;
|
|
570
|
+
if (!allParsed.has(refStr)) {
|
|
571
|
+
addViolation(rfcId, relFile, "V-19", `amends "${refStr}" does not match any existing RFC`);
|
|
572
|
+
} else {
|
|
573
|
+
const otherAmendedBy = allParsed.get(refStr)!.parsed.frontmatter["amendedBy"];
|
|
574
|
+
const arr = Array.isArray(otherAmendedBy) ? otherAmendedBy.map(String) : [];
|
|
575
|
+
if (!arr.includes(rfcId)) {
|
|
576
|
+
addViolation(
|
|
577
|
+
rfcId,
|
|
578
|
+
relFile,
|
|
579
|
+
"V-19",
|
|
580
|
+
`${rfcId}.amends includes ${refStr}, but ${refStr}.amendedBy does not include ${rfcId}`,
|
|
581
|
+
"warning",
|
|
582
|
+
);
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
const amendedBy = fm["amendedBy"];
|
|
588
|
+
if (Array.isArray(amendedBy)) {
|
|
589
|
+
for (const ref of amendedBy) {
|
|
590
|
+
const refStr = String(ref);
|
|
591
|
+
if (!refStr) continue;
|
|
592
|
+
if (!allParsed.has(refStr)) {
|
|
593
|
+
addViolation(
|
|
594
|
+
rfcId,
|
|
595
|
+
relFile,
|
|
596
|
+
"V-19",
|
|
597
|
+
`amendedBy "${refStr}" does not match any existing RFC`,
|
|
598
|
+
);
|
|
599
|
+
} else {
|
|
600
|
+
const otherAmends = allParsed.get(refStr)!.parsed.frontmatter["amends"];
|
|
601
|
+
const arr = Array.isArray(otherAmends) ? otherAmends.map(String) : [];
|
|
602
|
+
if (!arr.includes(rfcId)) {
|
|
603
|
+
addViolation(
|
|
604
|
+
rfcId,
|
|
605
|
+
relFile,
|
|
606
|
+
"V-19",
|
|
607
|
+
`${rfcId}.amendedBy includes ${refStr}, but ${refStr}.amends does not include ${rfcId}`,
|
|
608
|
+
"warning",
|
|
609
|
+
);
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
// V-21: filename must be lowercase kebab-case
|
|
616
|
+
const lowerRfcId = rfcId.toLowerCase();
|
|
617
|
+
const fileBasename = fileName.split("/").pop()!.split("\\").pop()!;
|
|
618
|
+
if (!fileBasename.startsWith(lowerRfcId)) {
|
|
619
|
+
addViolation(
|
|
620
|
+
rfcId,
|
|
621
|
+
relFile,
|
|
622
|
+
"V-21",
|
|
623
|
+
`Filename "${fileBasename}" must start with lowercase "${lowerRfcId}"`,
|
|
624
|
+
);
|
|
625
|
+
} else if (!/^[a-z0-9-]+\.md$/.test(fileBasename)) {
|
|
626
|
+
addViolation(
|
|
627
|
+
rfcId,
|
|
628
|
+
relFile,
|
|
629
|
+
"V-21",
|
|
630
|
+
`Filename "${fileName}" is not lowercase kebab-case (only a-z, 0-9, hyphens allowed)`,
|
|
631
|
+
);
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
// V-20: no unknown frontmatter keys
|
|
635
|
+
for (const key of Object.keys(fm)) {
|
|
636
|
+
if (!knownKeys.has(key)) {
|
|
637
|
+
addViolation(
|
|
638
|
+
rfcId,
|
|
639
|
+
relFile,
|
|
640
|
+
"V-20",
|
|
641
|
+
`unknown frontmatter key "${key}" (not in the RFC schema)`,
|
|
642
|
+
"warning",
|
|
643
|
+
);
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
// V-22: well-formed acceptance probe shape
|
|
648
|
+
for (const issue of validateAcceptanceShape(fm["acceptance"])) {
|
|
649
|
+
addViolation(rfcId, relFile, "V-22", issue.message, "warning");
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
// V-23: verification evidence for post-cutoff, probe-bearing, implemented RFCs
|
|
653
|
+
const createdAtStr = String(fm["createdAt"] ?? "");
|
|
654
|
+
const hasProbes = Array.isArray(fm["acceptance"]) && (fm["acceptance"] as unknown[]).length > 0;
|
|
655
|
+
if (createdAtStr >= RFC_METADATA_CUTOFF && hasProbes && status === "implemented") {
|
|
656
|
+
const slug = rfcId.toLowerCase();
|
|
657
|
+
const evidenceRelPath = path.join(RFC_DIR, "verification", `${slug}.generated.yaml`);
|
|
658
|
+
const evidenceAbsPath = path.join(workspaceRoot, evidenceRelPath);
|
|
659
|
+
let evidenceOk = false;
|
|
660
|
+
let evidenceOverall = "";
|
|
661
|
+
try {
|
|
662
|
+
const evidenceContent = await readFile(evidenceAbsPath, "utf-8");
|
|
663
|
+
const evidence = yamlParse(evidenceContent) as { overall?: string };
|
|
664
|
+
evidenceOverall = String(evidence.overall ?? "");
|
|
665
|
+
evidenceOk = evidenceOverall === "pass";
|
|
666
|
+
} catch {
|
|
667
|
+
// file missing or unparseable
|
|
668
|
+
}
|
|
669
|
+
if (!evidenceOk) {
|
|
670
|
+
addViolation(
|
|
671
|
+
rfcId,
|
|
672
|
+
relFile,
|
|
673
|
+
"V-23",
|
|
674
|
+
`status is "implemented" with acceptance probes and createdAt >= ${RFC_METADATA_CUTOFF}, ` +
|
|
675
|
+
`but evidence file ${evidenceRelPath} is missing, unparseable, or overall is not "pass" ` +
|
|
676
|
+
`(got "${evidenceOverall || "missing"}"). Run: site-kernel run rfc.verification.emit --id ${rfcId}`,
|
|
677
|
+
);
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
// V-25: reviewer identity on newly decided RFCs (RFC-0335)
|
|
682
|
+
{
|
|
683
|
+
const reviewers = fm["reviewers"];
|
|
684
|
+
const reviewerArr = Array.isArray(reviewers) ? (reviewers as unknown[]).map(String) : [];
|
|
685
|
+
const isDecided = DECIDED_STATUSES.includes(status as RfcStatus);
|
|
686
|
+
const isPostCutoff = createdAtStr >= RFC_METADATA_CUTOFF;
|
|
687
|
+
|
|
688
|
+
if (isPostCutoff && isDecided && reviewerArr.length === 0) {
|
|
689
|
+
addViolation(
|
|
690
|
+
rfcId,
|
|
691
|
+
relFile,
|
|
692
|
+
"V-25",
|
|
693
|
+
`${status} RFC created ${createdAtStr} has empty reviewers — the deciding human must record their identity (RFC-0335).`,
|
|
694
|
+
);
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
for (const entry of reviewerArr) {
|
|
698
|
+
if (!REVIEWER_IDENTITY_PATTERN.test(entry)) {
|
|
699
|
+
addViolation(
|
|
700
|
+
rfcId,
|
|
701
|
+
relFile,
|
|
702
|
+
"V-25",
|
|
703
|
+
`reviewer "${entry}" does not match the identity format ^(human|agent):[a-z0-9][a-z0-9-]*$ (RFC-0335).`,
|
|
704
|
+
);
|
|
705
|
+
} else if (entry.startsWith("agent:")) {
|
|
706
|
+
addViolation(
|
|
707
|
+
rfcId,
|
|
708
|
+
relFile,
|
|
709
|
+
"V-25",
|
|
710
|
+
`agent reviewer identities are reserved until an RFC grants AI reviewer authority over RFC governance (see RFC-0279 for the pattern) (RFC-0335).`,
|
|
711
|
+
);
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
// V-24: satisfies DNA-trace frontmatter (RFC-0331)
|
|
717
|
+
{
|
|
718
|
+
const satisfies = fm["satisfies"];
|
|
719
|
+
const satisfiesArr = Array.isArray(satisfies) ? (satisfies as unknown[]).map(String) : [];
|
|
720
|
+
const satisfiesPresent = satisfiesArr.length > 0;
|
|
721
|
+
const kind = String(fm["kind"] ?? "");
|
|
722
|
+
const requiresSatisfies =
|
|
723
|
+
createdAtStr >= RFC_METADATA_CUTOFF && (kind === "architecture" || kind === "contract");
|
|
724
|
+
|
|
725
|
+
// Format: all RFCs, any age — each entry must match ^DNA-\d+$
|
|
726
|
+
if (satisfiesPresent) {
|
|
727
|
+
for (const entry of satisfiesArr) {
|
|
728
|
+
if (!/^DNA-\d+$/.test(entry)) {
|
|
729
|
+
addViolation(
|
|
730
|
+
rfcId,
|
|
731
|
+
relFile,
|
|
732
|
+
"V-24",
|
|
733
|
+
`satisfies entry "${entry}" does not match the format ^DNA-\\d+$ (RFC-0331).`,
|
|
734
|
+
);
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
// Presence: post-cutoff architecture/contract RFCs must have non-empty satisfies
|
|
740
|
+
if (requiresSatisfies && !satisfiesPresent) {
|
|
741
|
+
addViolation(
|
|
742
|
+
rfcId,
|
|
743
|
+
relFile,
|
|
744
|
+
"V-24",
|
|
745
|
+
`${kind} RFC created ${createdAtStr} (>= ${RFC_METADATA_CUTOFF}) must declare at least one DNA invariant in satisfies (RFC-0331).`,
|
|
746
|
+
);
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
// V-28: RFC-id monotonicity (RFC-0478) — no RFC may have an id lower
|
|
751
|
+
// than the maximum id among RFCs with a strictly earlier createdAt.
|
|
752
|
+
// Same-day RFCs (equal createdAt) are unconstrained relative to each other.
|
|
753
|
+
// Only applies to RFCs created strictly after RFC_VERSION_BUMP_CUTOFF;
|
|
754
|
+
// pre-cutoff and same-day RFCs predate the rule and are exempt.
|
|
755
|
+
// All RFCs (including pre-cutoff) participate in the comparison set.
|
|
756
|
+
{
|
|
757
|
+
const currentIdNum = parseInt(rfcId.replace(/^RFC-/, ""), 10);
|
|
758
|
+
const currentCreatedAt = String(fm["createdAt"] ?? "");
|
|
759
|
+
if (currentCreatedAt > RFC_VERSION_BUMP_CUTOFF) {
|
|
760
|
+
let maxEarlierId = 0;
|
|
761
|
+
for (const [otherId, other] of allParsed) {
|
|
762
|
+
if (otherId === rfcId) continue;
|
|
763
|
+
const otherCreatedAt = String(other.parsed.frontmatter["createdAt"] ?? "");
|
|
764
|
+
if (otherCreatedAt < currentCreatedAt) {
|
|
765
|
+
const otherIdNum = parseInt(otherId.replace(/^RFC-/, ""), 10);
|
|
766
|
+
if (otherIdNum > maxEarlierId) {
|
|
767
|
+
maxEarlierId = otherIdNum;
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
if (currentIdNum < maxEarlierId) {
|
|
772
|
+
addViolation(
|
|
773
|
+
rfcId,
|
|
774
|
+
relFile,
|
|
775
|
+
"V-28",
|
|
776
|
+
`RFC-id ${rfcId} (createdAt ${currentCreatedAt}) is lower than RFC-${maxEarlierId} which has a strictly earlier createdAt. RFC-ids must be monotonically non-decreasing with respect to createdAt (RFC-0478).`,
|
|
777
|
+
);
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
// V-29: versionBump required for post-cutoff accepted/implemented RFCs (RFC-0478, ADR-0029)
|
|
783
|
+
{
|
|
784
|
+
const versionBump = fm["versionBump"];
|
|
785
|
+
const isPostCutoff = createdAtStr >= RFC_VERSION_BUMP_CUTOFF;
|
|
786
|
+
const requiresVersionBump = status === "implemented" || status === "accepted";
|
|
787
|
+
|
|
788
|
+
if (isPostCutoff && requiresVersionBump && versionBump === undefined) {
|
|
789
|
+
addViolation(
|
|
790
|
+
rfcId,
|
|
791
|
+
relFile,
|
|
792
|
+
"V-29",
|
|
793
|
+
`status is "${status}" and createdAt >= ${RFC_VERSION_BUMP_CUTOFF}, but versionBump is absent. Post-cutoff accepted/implemented RFCs must declare versionBump (RFC-0478).`,
|
|
794
|
+
);
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
if (versionBump === "none") {
|
|
798
|
+
const commands = fm["commands"] as Record<string, unknown> | undefined;
|
|
799
|
+
const added = Array.isArray(commands?.added) ? (commands!.added as unknown[]) : [];
|
|
800
|
+
const changed = Array.isArray(commands?.changed) ? (commands!.changed as unknown[]) : [];
|
|
801
|
+
if (added.length > 0 || changed.length > 0) {
|
|
802
|
+
addViolation(
|
|
803
|
+
rfcId,
|
|
804
|
+
relFile,
|
|
805
|
+
"V-29",
|
|
806
|
+
`versionBump is "none" but commands.added or commands.changed is non-empty. Commands imply code changes; use "patch" or "minor" instead (RFC-0478).`,
|
|
807
|
+
"warning",
|
|
808
|
+
);
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
// V-30: breaksC field consistency (RFC-0480)
|
|
814
|
+
// If breaksC: true — @warpgogol/ontology must be in packagesImpacted.
|
|
815
|
+
// If breaksC absent/false but @warpgol/ontology in packagesImpacted — warning.
|
|
816
|
+
// Skip for archived RFCs — package names changed during ecosystem consolidation (RFC-0776):
|
|
817
|
+
// @warpgogol/ontology → @warpgogol/werkstatt-shared/ontology
|
|
818
|
+
{
|
|
819
|
+
const breaksC = fm["breaksC"];
|
|
820
|
+
const packagesImpacted = Array.isArray(fm["packagesImpacted"])
|
|
821
|
+
? (fm["packagesImpacted"] as unknown[]).map(String)
|
|
822
|
+
: [];
|
|
823
|
+
const hasOntology = packagesImpacted.some((p) =>
|
|
824
|
+
p.includes("@warpgogol/werkstatt-shared/ontology"),
|
|
825
|
+
);
|
|
826
|
+
|
|
827
|
+
if (breaksC === true && !hasOntology && !isArchived) {
|
|
828
|
+
addViolation(
|
|
829
|
+
rfcId,
|
|
830
|
+
relFile,
|
|
831
|
+
"V-30",
|
|
832
|
+
`breaksC is true but @warpgogol/ontology is not in packagesImpacted. RFCs that break Layer C must update the declarative C-contract in packages/ontology/src/external-surfaces/ (RFC-0480).`,
|
|
833
|
+
);
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
if (breaksC !== true && hasOntology) {
|
|
837
|
+
addViolation(
|
|
838
|
+
rfcId,
|
|
839
|
+
relFile,
|
|
840
|
+
"V-30",
|
|
841
|
+
`@warpgogol/ontology is in packagesImpacted but breaksC is not true. If this RFC modifies packages/ontology/src/external-surfaces/, declare breaksC: true (RFC-0480).`,
|
|
842
|
+
"warning",
|
|
843
|
+
);
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
// V-31: filename-number uniqueness and filename/id consistency (RFC-0491)
|
|
848
|
+
if (seenFilenameNumbers) {
|
|
849
|
+
const fileBasename = fileName.split("/").pop()!.split("\\").pop()!;
|
|
850
|
+
const fileNumMatch = fileBasename.match(/^rfc-(\d{4})/);
|
|
851
|
+
if (fileNumMatch) {
|
|
852
|
+
const fileNum = parseInt(fileNumMatch[1]!, 10);
|
|
853
|
+
const idNum = parseInt(rfcId.replace(/^RFC-/, ""), 10);
|
|
854
|
+
|
|
855
|
+
// Sub-check A: filename number must match frontmatter id number
|
|
856
|
+
if (!Number.isNaN(idNum) && fileNum !== idNum) {
|
|
857
|
+
addViolation(
|
|
858
|
+
rfcId,
|
|
859
|
+
relFile,
|
|
860
|
+
"V-31",
|
|
861
|
+
`Filename number ${String(fileNum).padStart(4, "0")} does not match frontmatter id ${rfcId}`,
|
|
862
|
+
);
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
// Sub-check B: filename number must be unique across the full tree
|
|
866
|
+
const prevNumFile = seenFilenameNumbers.get(fileNum);
|
|
867
|
+
if (prevNumFile) {
|
|
868
|
+
addViolation(
|
|
869
|
+
rfcId,
|
|
870
|
+
relFile,
|
|
871
|
+
"V-31",
|
|
872
|
+
`Duplicate filename number ${String(fileNum).padStart(4, "0")} — also in ${prevNumFile}`,
|
|
873
|
+
);
|
|
874
|
+
} else {
|
|
875
|
+
seenFilenameNumbers.set(fileNum, relFile);
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
// V-32: implementation commit drift detection (RFC-0625)
|
|
881
|
+
// Warns when implement: RFC-XXXX commits exist in git history since createdAt
|
|
882
|
+
// but the RFC status is not yet "implemented".
|
|
883
|
+
{
|
|
884
|
+
const driftResult = await checkImplementationCommitDrift(
|
|
885
|
+
workspaceRoot,
|
|
886
|
+
rfcId,
|
|
887
|
+
createdAt,
|
|
888
|
+
status,
|
|
889
|
+
);
|
|
890
|
+
if (driftResult.found) {
|
|
891
|
+
addViolation(
|
|
892
|
+
rfcId,
|
|
893
|
+
relFile,
|
|
894
|
+
"V-32",
|
|
895
|
+
`${rfcId} has ${driftResult.commitCount} implement: commit(s) in git history since ${createdAt} but status is still "${status}". Run rfc.implement.stamp to transition to implemented.`,
|
|
896
|
+
"warning",
|
|
897
|
+
);
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
// V-33: dependsOn referential integrity, self-dependency, rejected-dependency deadlock (RFC-0795)
|
|
902
|
+
const dependsOn = fm["dependsOn"];
|
|
903
|
+
if (Array.isArray(dependsOn)) {
|
|
904
|
+
for (const depRef of dependsOn) {
|
|
905
|
+
const depStr = String(depRef);
|
|
906
|
+
if (!depStr) continue;
|
|
907
|
+
if (depStr === rfcId) {
|
|
908
|
+
addViolation(
|
|
909
|
+
rfcId,
|
|
910
|
+
relFile,
|
|
911
|
+
"V-33",
|
|
912
|
+
`dependsOn includes itself — an RFC cannot depend on itself`,
|
|
913
|
+
"warning",
|
|
914
|
+
);
|
|
915
|
+
} else if (!allParsed.has(depStr)) {
|
|
916
|
+
addViolation(
|
|
917
|
+
rfcId,
|
|
918
|
+
relFile,
|
|
919
|
+
"V-33",
|
|
920
|
+
`dependsOn "${depStr}" does not match any existing RFC`,
|
|
921
|
+
"warning",
|
|
922
|
+
);
|
|
923
|
+
} else {
|
|
924
|
+
const depStatus = String(allParsed.get(depStr)!.parsed.frontmatter["status"] ?? "");
|
|
925
|
+
if (depStatus === "rejected") {
|
|
926
|
+
addViolation(
|
|
927
|
+
rfcId,
|
|
928
|
+
relFile,
|
|
929
|
+
"V-33",
|
|
930
|
+
`dependsOn "${depStr}" has status "rejected" — this dependency will never be satisfied. Remove the entry or supersede the rejected RFC.`,
|
|
931
|
+
"warning",
|
|
932
|
+
);
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
// V-34: batch slug format (RFC-0795)
|
|
939
|
+
const batchSlug = fm["batch"];
|
|
940
|
+
if (batchSlug !== undefined && batchSlug !== null) {
|
|
941
|
+
const batchStr = String(batchSlug);
|
|
942
|
+
if (batchStr && !/^[a-z0-9]+(-[a-z0-9]+)*$/.test(batchStr)) {
|
|
943
|
+
addViolation(
|
|
944
|
+
rfcId,
|
|
945
|
+
relFile,
|
|
946
|
+
"V-34",
|
|
947
|
+
`batch slug "${batchStr}" does not match kebab-case pattern /^[a-z0-9]+(-[a-z0-9]+)*$/`,
|
|
948
|
+
"warning",
|
|
949
|
+
);
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
// V-NC-01: NEEDS CLARIFICATION marker detection (RFC-0709)
|
|
954
|
+
const ncMarkers = collectMarkers(body, status, createdAt);
|
|
955
|
+
for (const marker of ncMarkers) {
|
|
956
|
+
addViolation(
|
|
957
|
+
rfcId,
|
|
958
|
+
relFile,
|
|
959
|
+
"V-NC-01",
|
|
960
|
+
`Unresolved NEEDS CLARIFICATION marker at line ${marker.line}: "${marker.text}"`,
|
|
961
|
+
marker.severity === "error" ? "error" : "warning",
|
|
962
|
+
);
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
return ncMarkers;
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
// ─── V-RFC-33: frontmatter YAML parseability (RFC-0755) ────────────────────
|
|
969
|
+
|
|
970
|
+
export function checkFrontmatterYamlParse(
|
|
971
|
+
fileName: string,
|
|
972
|
+
result: ReadAndParseRfcResult | undefined,
|
|
973
|
+
addViolation: AddViolationFn,
|
|
974
|
+
): void {
|
|
975
|
+
if (result && "error" in result) {
|
|
976
|
+
const relFile = path.join(RFC_DIR, fileName);
|
|
977
|
+
addViolation(
|
|
978
|
+
"UNKNOWN",
|
|
979
|
+
relFile,
|
|
980
|
+
"V-RFC-33",
|
|
981
|
+
`RFC frontmatter YAML parse error in ${fileName}: ${result.error}`,
|
|
982
|
+
);
|
|
983
|
+
}
|
|
984
|
+
}
|