@warpgogol/forge 4.2.1 → 4.2.3
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 +5 -3
- package/README.md +2 -0
- package/README.uk.md +2 -0
- package/bin/cli.ts +8 -0
- package/dist/bin/cli.js +8 -0
- package/dist/bin/cli.js.map +1 -1
- package/dist/os/compass/compass.module.d.ts.map +1 -1
- package/dist/os/compass/compass.module.js +9 -4
- package/dist/os/compass/compass.module.js.map +1 -1
- package/dist/os/compass/handlers/compass-audit-handler.d.ts +1 -0
- package/dist/os/compass/handlers/compass-audit-handler.d.ts.map +1 -1
- package/dist/os/compass/handlers/compass-audit-handler.js +3 -0
- package/dist/os/compass/handlers/compass-audit-handler.js.map +1 -1
- package/dist/os/compass/handlers/compass-change-summary-handler.d.ts.map +1 -1
- package/dist/os/compass/handlers/compass-change-summary-handler.js +36 -6
- package/dist/os/compass/handlers/compass-change-summary-handler.js.map +1 -1
- package/dist/os/compass/handlers/compass-inventory-handler.d.ts +2 -1
- package/dist/os/compass/handlers/compass-inventory-handler.d.ts.map +1 -1
- package/dist/os/compass/handlers/compass-inventory-handler.js +54 -7
- package/dist/os/compass/handlers/compass-inventory-handler.js.map +1 -1
- package/dist/os/compass/handlers/compass-inventory.d.ts +17 -0
- package/dist/os/compass/handlers/compass-inventory.d.ts.map +1 -1
- package/dist/os/compass/handlers/compass-inventory.js +277 -12
- package/dist/os/compass/handlers/compass-inventory.js.map +1 -1
- package/dist/os/core/core.module.d.ts.map +1 -1
- package/dist/os/core/core.module.js +26 -0
- package/dist/os/core/core.module.js.map +1 -1
- package/dist/os/core/handlers/file-size-lint.d.ts +8 -0
- package/dist/os/core/handlers/file-size-lint.d.ts.map +1 -0
- package/dist/os/core/handlers/file-size-lint.js +166 -0
- package/dist/os/core/handlers/file-size-lint.js.map +1 -0
- package/dist/os/core/handlers/public-surface.d.ts.map +1 -1
- package/dist/os/core/handlers/public-surface.js +4 -3
- package/dist/os/core/handlers/public-surface.js.map +1 -1
- package/os/compass/compass.module.ts +12 -7
- package/os/compass/handlers/compass-audit-handler.ts +4 -0
- package/os/compass/handlers/compass-change-summary-handler.ts +45 -6
- package/os/compass/handlers/compass-inventory-handler.ts +78 -9
- package/os/compass/handlers/compass-inventory.ts +339 -13
- package/os/compass/handlers/tests/compass-v2-contract.test.ts +239 -0
- package/os/core/core.module.ts +28 -0
- package/os/core/handlers/file-size-lint.ts +200 -0
- package/os/core/handlers/public-surface.ts +4 -3
- package/package.json +12 -12
- package/skills/fo/fo-compass-annotate/SKILL.md +13 -11
- package/skills/fo/fo-compass-annotate/templates/audit-prompt.md +7 -1
- package/skills/fo/fo-compass-annotate/templates/header-format.md +33 -11
- package/skills/fo/fo-compass-annotate/templates/header-prompt.md +12 -1
- package/src/tests/create.test.ts +17 -17
- package/src/tests/file-size-lint.test.ts +181 -0
- package/src/tests/public-surface.test.ts +13 -4
- package/src/tests/scaffold-project.test.ts +1 -1
|
@@ -17,12 +17,19 @@ contract block specs alongside built-in MODULE_CONTRACT and CHANGE_SUMMARY check
|
|
|
17
17
|
<item>RFC-0556: moved from @warpgogol/site-kernel-checks to @warpgogol/forge for autonomous mode.</item>
|
|
18
18
|
<item>Game extensions: added COMPASS-SYNTAX-01 diagnostic validating comment syntax per file type (.gd needs # prefix, .tscn/.tres need ; prefix, .ts/.cs need block comment).</item>
|
|
19
19
|
<item>RFC-0943: added COMPASS-PLUGIN-01/02/03 diagnostics for pack-declared contract block specs from forge.plugin.yaml extensionPoints.</item>
|
|
20
|
+
<item>RFC-1094: --mode warning|error on compass.validate; mode-aware v2 diagnostics (KD/ORDER/PURPOSE) with severity field; inventory data.entries now carries full entries and XML gains v2 fields.</item>
|
|
20
21
|
</CHANGE_SUMMARY>
|
|
21
22
|
*/
|
|
22
23
|
|
|
23
24
|
import { resolve } from "node:path";
|
|
24
25
|
import { readFile } from "node:fs/promises";
|
|
25
|
-
import {
|
|
26
|
+
import {
|
|
27
|
+
createCompassInventoryEntries,
|
|
28
|
+
evaluateV2Rules,
|
|
29
|
+
getEntrySource,
|
|
30
|
+
resolveCompassMode,
|
|
31
|
+
type CompassInventoryEntry,
|
|
32
|
+
} from "./compass-inventory.ts";
|
|
26
33
|
import { resolveCompassScanRoot } from "./resolve-scan-root.ts";
|
|
27
34
|
import { writeFileIfChanged } from "../../../src/utils/fs-idempotent.ts";
|
|
28
35
|
import { loadForgeConfig } from "../../../src/config/forge-config.ts";
|
|
@@ -201,6 +208,21 @@ function renderInventoryXml(
|
|
|
201
208
|
lines.push(
|
|
202
209
|
` <has-change-summary>${entry.hasChangeSummary ? "true" : "false"}</has-change-summary>`,
|
|
203
210
|
);
|
|
211
|
+
lines.push(
|
|
212
|
+
` <has-key-decisions>${entry.hasKeyDecisions ? "true" : "false"}</has-key-decisions>`,
|
|
213
|
+
);
|
|
214
|
+
lines.push(
|
|
215
|
+
` <key-decisions-item-count>${entry.keyDecisionsItemCount}</key-decisions-item-count>`,
|
|
216
|
+
);
|
|
217
|
+
lines.push(
|
|
218
|
+
` <key-decisions-required>${entry.keyDecisionsRequired ? "true" : "false"}</key-decisions-required>`,
|
|
219
|
+
);
|
|
220
|
+
lines.push(
|
|
221
|
+
` <change-summary-item-count>${entry.changeSummaryItemCount}</change-summary-item-count>`,
|
|
222
|
+
);
|
|
223
|
+
if (entry.historyIds.length > 0) {
|
|
224
|
+
lines.push(` <history-ids>${escapeXml(entry.historyIds.join(", "))}</history-ids>`);
|
|
225
|
+
}
|
|
204
226
|
lines.push(
|
|
205
227
|
` <has-ai-invariant>${entry.hasAiInvariant ? "true" : "false"}</has-ai-invariant>`,
|
|
206
228
|
);
|
|
@@ -233,7 +255,11 @@ export async function runCompassInventory(
|
|
|
233
255
|
input: ForgeCommandInput,
|
|
234
256
|
context: ForgeRuntimeContext,
|
|
235
257
|
): Promise<
|
|
236
|
-
ForgeCommandResult<{
|
|
258
|
+
ForgeCommandResult<{
|
|
259
|
+
entries: CompassInventoryEntry[];
|
|
260
|
+
outputPath: string;
|
|
261
|
+
summary: CompassInventorySummary;
|
|
262
|
+
}>
|
|
237
263
|
> {
|
|
238
264
|
const scanRoot = resolveCompassScanRoot(input, context) ?? context.workspaceRoot;
|
|
239
265
|
const entries = await createCompassInventoryEntries(scanRoot, input);
|
|
@@ -248,7 +274,7 @@ export async function runCompassInventory(
|
|
|
248
274
|
`[compass.inventory] dry-run active — skipped writing ${INVENTORY_OUTPUT_PATH}`,
|
|
249
275
|
);
|
|
250
276
|
return {
|
|
251
|
-
data: { entries
|
|
277
|
+
data: { entries, outputPath: INVENTORY_OUTPUT_PATH, summary },
|
|
252
278
|
summary: `[compass.inventory] previewed ${INVENTORY_OUTPUT_PATH}`,
|
|
253
279
|
nextSteps: [
|
|
254
280
|
{
|
|
@@ -264,7 +290,7 @@ export async function runCompassInventory(
|
|
|
264
290
|
await writeFileIfChanged(outputPath, xml);
|
|
265
291
|
|
|
266
292
|
return {
|
|
267
|
-
data: { entries
|
|
293
|
+
data: { entries, outputPath, summary },
|
|
268
294
|
summary: `[compass.inventory] ${context.dryRun ? "previewed" : "wrote"} ${INVENTORY_OUTPUT_PATH}`,
|
|
269
295
|
nextSteps: context.dryRun
|
|
270
296
|
? [
|
|
@@ -300,6 +326,15 @@ export async function runCompassValidation(
|
|
|
300
326
|
}>
|
|
301
327
|
> {
|
|
302
328
|
const scanRoot = resolveCompassScanRoot(input, context);
|
|
329
|
+
let mode: ReturnType<typeof resolveCompassMode>;
|
|
330
|
+
try {
|
|
331
|
+
mode = resolveCompassMode(input);
|
|
332
|
+
} catch {
|
|
333
|
+
context.logger.error(
|
|
334
|
+
`[compass.validate] invalid --mode value: ${String(input.flags["mode"])} (expected warning|error)`,
|
|
335
|
+
);
|
|
336
|
+
return { exitCode: 1, summary: "invalid --mode value" };
|
|
337
|
+
}
|
|
303
338
|
const entries = await createCompassInventoryEntries(context.workspaceRoot, input, scanRoot);
|
|
304
339
|
const summary = summarizeInventory(entries);
|
|
305
340
|
const failures = entries.filter(
|
|
@@ -311,13 +346,45 @@ export async function runCompassValidation(
|
|
|
311
346
|
|
|
312
347
|
const diagnostics: Array<{
|
|
313
348
|
ruleId: string;
|
|
314
|
-
severity:
|
|
349
|
+
severity: "warning" | "error";
|
|
315
350
|
file: string;
|
|
316
351
|
message: string;
|
|
317
352
|
fix: string;
|
|
318
353
|
pack?: string;
|
|
319
354
|
}> = [];
|
|
320
355
|
|
|
356
|
+
// RFC-1094: v2 rules are mode-aware — warnings in `warning` mode (default
|
|
357
|
+
// during the migration window), errors in `error` mode. Owned subset:
|
|
358
|
+
// COMPASS-KD-*, COMPASS-ORDER-01, COMPASS-PURPOSE-*. CS-* rules are emitted
|
|
359
|
+
// by compass.changesummary.validate.
|
|
360
|
+
const V2_VALIDATE_RULE_PREFIXES = ["COMPASS-KD-", "COMPASS-ORDER-", "COMPASS-PURPOSE-"];
|
|
361
|
+
const v2Severity = mode === "error" ? "error" : "warning";
|
|
362
|
+
const authoredEntries = entries.filter(
|
|
363
|
+
(entry) => entry.authoringStatus === "authored" && entry.requiredScaffolding !== "none",
|
|
364
|
+
);
|
|
365
|
+
for (const entry of authoredEntries) {
|
|
366
|
+
const absPath = resolve(context.workspaceRoot, entry.path);
|
|
367
|
+
const source = getEntrySource(entry) ?? (await readFile(absPath, "utf8"));
|
|
368
|
+
for (const v2 of evaluateV2Rules(entry, source)) {
|
|
369
|
+
if (!V2_VALIDATE_RULE_PREFIXES.some((prefix) => v2.ruleId.startsWith(prefix))) {
|
|
370
|
+
continue;
|
|
371
|
+
}
|
|
372
|
+
const log = `[compass.validate] ${v2.ruleId}: ${entry.path}: ${v2.message}`;
|
|
373
|
+
if (v2Severity === "error") {
|
|
374
|
+
context.logger.error(log);
|
|
375
|
+
} else {
|
|
376
|
+
context.logger.warn(log);
|
|
377
|
+
}
|
|
378
|
+
diagnostics.push({
|
|
379
|
+
ruleId: v2.ruleId,
|
|
380
|
+
severity: v2Severity,
|
|
381
|
+
file: entry.path,
|
|
382
|
+
message: v2.message,
|
|
383
|
+
fix: v2.fix,
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
321
388
|
for (const failure of failures) {
|
|
322
389
|
for (const violation of failure.violations) {
|
|
323
390
|
let ruleId = "COMPASS-CONTRACT-01";
|
|
@@ -477,7 +544,9 @@ export async function runCompassValidation(
|
|
|
477
544
|
}
|
|
478
545
|
}
|
|
479
546
|
|
|
480
|
-
const
|
|
547
|
+
const hasErrors = diagnostics.some((d) => d.severity === "error");
|
|
548
|
+
const warningCount =
|
|
549
|
+
diagnostics.length - diagnostics.filter((d) => d.severity === "error").length;
|
|
481
550
|
|
|
482
551
|
return {
|
|
483
552
|
data: {
|
|
@@ -486,9 +555,9 @@ export async function runCompassValidation(
|
|
|
486
555
|
summary,
|
|
487
556
|
diagnostics,
|
|
488
557
|
},
|
|
489
|
-
exitCode:
|
|
490
|
-
summary:
|
|
558
|
+
exitCode: hasErrors ? 1 : 0,
|
|
559
|
+
summary: hasErrors
|
|
491
560
|
? undefined
|
|
492
|
-
: `[compass.validate] OK (${summary.authoredFiles} authored files checked)`,
|
|
561
|
+
: `[compass.validate] OK (${summary.authoredFiles} authored files checked${warningCount > 0 ? `, ${warningCount} v2 warnings` : ""})`,
|
|
493
562
|
};
|
|
494
563
|
}
|
|
@@ -16,6 +16,7 @@ for Compass source-file inventory.</purpose>
|
|
|
16
16
|
<item>RFC-0556: moved canonical implementation from @warpgogol/site-kernel to @warpgogol/forge for autonomous mode.</item>
|
|
17
17
|
<item>Game extensions: added .cs, .tscn, .tres, .gd to SOURCE_EXTENSIONS; createCompassInventoryEntries now reads forge.yaml compass.fileExtensions at runtime and merges with hardcoded set.</item>
|
|
18
18
|
<item>Added .md to SOURCE_EXTENSIONS for SKILL.md Compass coverage; detectAuthoringStatus excludes non-SKILL.md markdown files.</item>
|
|
19
|
+
<item>RFC-1094: v2 contract — KEY_DECISIONS/history parsing, new inventory fields, evaluateV2Rules, deriveFileTokens, resolveCompassMode, shared GOVERNANCE_ID_RE.</item>
|
|
19
20
|
</CHANGE_SUMMARY>
|
|
20
21
|
*/
|
|
21
22
|
|
|
@@ -84,7 +85,6 @@ const HIGH_RISK_EXACT_RELATIVE_PATHS = new Set([
|
|
|
84
85
|
"packages/os/site-kernel/src/runtime.ts",
|
|
85
86
|
"packages/os/site-kernel/src/types.ts",
|
|
86
87
|
]);
|
|
87
|
-
const REQUIRED_MARKERS = ["<MODULE_CONTRACT>", "<CHANGE_SUMMARY>"] as const;
|
|
88
88
|
function forbiddenMarkerPattern(tagName: string): RegExp {
|
|
89
89
|
return new RegExp(`<${tagName}\\b`);
|
|
90
90
|
}
|
|
@@ -96,6 +96,55 @@ const FORBIDDEN_PATTERNS: Array<{ name: string; regex: RegExp }> = [
|
|
|
96
96
|
{ name: "COMPASS_BLOCK", regex: new RegExp(`</?${"COMPASS_BLOCK"}\\b`) },
|
|
97
97
|
];
|
|
98
98
|
|
|
99
|
+
// RFC-1094: v2 contract constants. GOVERNANCE_ID_RE is the canonical
|
|
100
|
+
// governance-ID pattern shared with the change-summary handler.
|
|
101
|
+
export const GOVERNANCE_ID_RE = /\b([A-Z][A-Z0-9]*-)+\d+\b/;
|
|
102
|
+
const GOVERNANCE_ID_PREFIX_RE = /^([A-Z][A-Z0-9]*-)+\d+\b/;
|
|
103
|
+
const HISTORY_ID_RE = /^([A-Z][A-Z0-9]*-)+\d+$/;
|
|
104
|
+
const HISTORY_ID_PARTS_RE = /^(([A-Z][A-Z0-9]*-)+)(\d+)$/;
|
|
105
|
+
const TODO_PLACEHOLDER_RE = /^TODO\b/i;
|
|
106
|
+
|
|
107
|
+
const GENERIC_STEMS = new Set([
|
|
108
|
+
"index",
|
|
109
|
+
"styles",
|
|
110
|
+
"style",
|
|
111
|
+
"local",
|
|
112
|
+
"global",
|
|
113
|
+
"utils",
|
|
114
|
+
"helpers",
|
|
115
|
+
"types",
|
|
116
|
+
"constants",
|
|
117
|
+
"readme",
|
|
118
|
+
]);
|
|
119
|
+
const SYMBOL_EXTENSIONS = new Set([".ts", ".tsx", ".js", ".jsx", ".mjs", ".mts"]);
|
|
120
|
+
const EXPORTED_SYMBOL_RE =
|
|
121
|
+
/export\s+(?:async\s+)?(?:function|const|let|class|interface|type|enum)\s+(\w+)/g;
|
|
122
|
+
|
|
123
|
+
const PURPOSE_BOILERPLATE_PATTERNS: RegExp[] = [
|
|
124
|
+
/^(this file|the file|a file|this module|a module)\b/i,
|
|
125
|
+
/^(utility|utilities|helper|helpers|misc)\b/i,
|
|
126
|
+
/^(todo|placeholder|fixme)\b/i,
|
|
127
|
+
];
|
|
128
|
+
|
|
129
|
+
const KEY_DECISIONS_MAX_ITEMS = 7;
|
|
130
|
+
const KEY_DECISIONS_MAX_WORDS = 20;
|
|
131
|
+
const CHANGE_SUMMARY_MAX_ITEMS = 5;
|
|
132
|
+
|
|
133
|
+
export type CompassValidationMode = "warning" | "error";
|
|
134
|
+
|
|
135
|
+
export interface CompassV2Diagnostic {
|
|
136
|
+
ruleId: string;
|
|
137
|
+
message: string;
|
|
138
|
+
fix: string;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function resolveCompassMode(input: ForgeCommandInput): CompassValidationMode {
|
|
142
|
+
const raw = input.flags["mode"];
|
|
143
|
+
if (raw === undefined) return "warning";
|
|
144
|
+
if (raw === "warning" || raw === "error") return raw;
|
|
145
|
+
throw new Error(`--mode must be "warning" or "error", got "${String(raw)}"`);
|
|
146
|
+
}
|
|
147
|
+
|
|
99
148
|
type CompassWorkspaceKind = "app" | "package" | "service";
|
|
100
149
|
type CompassRiskClass = "high" | "medium" | "low";
|
|
101
150
|
type CompassComplexity = "non-trivial" | "trivial";
|
|
@@ -116,6 +165,11 @@ export interface CompassInventoryEntry {
|
|
|
116
165
|
nonEmptyLineCount: number;
|
|
117
166
|
hasModuleContract: boolean;
|
|
118
167
|
hasChangeSummary: boolean;
|
|
168
|
+
hasKeyDecisions: boolean;
|
|
169
|
+
keyDecisionsItemCount: number;
|
|
170
|
+
keyDecisionsRequired: boolean;
|
|
171
|
+
changeSummaryItemCount: number;
|
|
172
|
+
historyIds: string[];
|
|
119
173
|
hasAiInvariant: boolean;
|
|
120
174
|
hasPurpose: boolean;
|
|
121
175
|
hasNonGoals: boolean;
|
|
@@ -478,23 +532,274 @@ function detectComplianceViolations(
|
|
|
478
532
|
return violations;
|
|
479
533
|
}
|
|
480
534
|
|
|
535
|
+
interface CompassBlockParse {
|
|
536
|
+
hasModuleContract: boolean;
|
|
537
|
+
hasKeyDecisions: boolean;
|
|
538
|
+
hasChangeSummary: boolean;
|
|
539
|
+
keyDecisionsItems: string[];
|
|
540
|
+
changeSummaryItems: string[];
|
|
541
|
+
historyIds: string[];
|
|
542
|
+
historyRaw: string | null;
|
|
543
|
+
purposeText: string;
|
|
544
|
+
positions: {
|
|
545
|
+
moduleContract: number;
|
|
546
|
+
keyDecisions: number;
|
|
547
|
+
changeSummary: number;
|
|
548
|
+
};
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
function extractItems(block: string): string[] {
|
|
552
|
+
const items: string[] = [];
|
|
553
|
+
const re = /<item>([\s\S]*?)<\/item>/g;
|
|
554
|
+
let m: RegExpExecArray | null;
|
|
555
|
+
while ((m = re.exec(block)) !== null) {
|
|
556
|
+
items.push(m[1]!.trim());
|
|
557
|
+
}
|
|
558
|
+
return items;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
function parseCompassBlocks(source: string): CompassBlockParse {
|
|
562
|
+
const positions = {
|
|
563
|
+
moduleContract: source.indexOf("<MODULE_CONTRACT>"),
|
|
564
|
+
keyDecisions: source.indexOf("<KEY_DECISIONS>"),
|
|
565
|
+
changeSummary: source.indexOf("<CHANGE_SUMMARY>"),
|
|
566
|
+
};
|
|
567
|
+
|
|
568
|
+
const contractBlock = extractBlockContent(source, "MODULE_CONTRACT");
|
|
569
|
+
const keyDecisionsBlock = extractBlockContent(source, "KEY_DECISIONS");
|
|
570
|
+
const changeSummaryBlock = extractBlockContent(source, "CHANGE_SUMMARY");
|
|
571
|
+
|
|
572
|
+
let purposeText = "";
|
|
573
|
+
if (contractBlock) {
|
|
574
|
+
const purposeBlock = extractBlockContent(contractBlock, "purpose");
|
|
575
|
+
if (purposeBlock) {
|
|
576
|
+
purposeText = purposeBlock.replace(/<[^>]+>/g, " ").trim();
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
let historyRaw: string | null = null;
|
|
581
|
+
let historyIds: string[] = [];
|
|
582
|
+
if (changeSummaryBlock) {
|
|
583
|
+
const historyMatch = changeSummaryBlock.match(/<history>([\s\S]*?)<\/history>/);
|
|
584
|
+
if (historyMatch) {
|
|
585
|
+
historyRaw = historyMatch[1]!.trim();
|
|
586
|
+
historyIds = historyRaw
|
|
587
|
+
.split(",")
|
|
588
|
+
.map((token) => token.trim())
|
|
589
|
+
.filter(Boolean);
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
return {
|
|
594
|
+
hasModuleContract: positions.moduleContract >= 0,
|
|
595
|
+
hasKeyDecisions: positions.keyDecisions >= 0,
|
|
596
|
+
hasChangeSummary: positions.changeSummary >= 0,
|
|
597
|
+
keyDecisionsItems: keyDecisionsBlock ? extractItems(keyDecisionsBlock) : [],
|
|
598
|
+
changeSummaryItems: changeSummaryBlock ? extractItems(changeSummaryBlock) : [],
|
|
599
|
+
historyIds,
|
|
600
|
+
historyRaw,
|
|
601
|
+
purposeText,
|
|
602
|
+
positions,
|
|
603
|
+
};
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
function splitIdentifier(name: string): string[] {
|
|
607
|
+
return name
|
|
608
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
|
|
609
|
+
.replace(/[_-]+/g, " ")
|
|
610
|
+
.toLowerCase()
|
|
611
|
+
.split(" ")
|
|
612
|
+
.filter(Boolean);
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
export function deriveFileTokens(pathFromRoot: string, source: string): Set<string> {
|
|
616
|
+
const segments = pathFromRoot.split("/").filter(Boolean);
|
|
617
|
+
const filename = segments[segments.length - 1] ?? "";
|
|
618
|
+
const dotIndex = filename.lastIndexOf(".");
|
|
619
|
+
const stem = dotIndex > 0 ? filename.slice(0, dotIndex) : filename;
|
|
620
|
+
const extension = dotIndex > 0 ? filename.slice(dotIndex) : "";
|
|
621
|
+
|
|
622
|
+
const tokens = new Set<string>();
|
|
623
|
+
for (const segment of stem.split("-").filter(Boolean)) {
|
|
624
|
+
tokens.add(segment.toLowerCase());
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
if (SYMBOL_EXTENSIONS.has(extension)) {
|
|
628
|
+
for (const match of source.matchAll(EXPORTED_SYMBOL_RE)) {
|
|
629
|
+
for (const word of splitIdentifier(match[1]!)) {
|
|
630
|
+
tokens.add(word);
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
if (GENERIC_STEMS.has(stem.toLowerCase())) {
|
|
636
|
+
const parent = segments[segments.length - 2] ?? "";
|
|
637
|
+
for (const segment of parent.split("-").filter(Boolean)) {
|
|
638
|
+
tokens.add(segment.toLowerCase());
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
return tokens;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
function validateHistoryIds(historyIds: string[]): string | null {
|
|
646
|
+
for (const token of historyIds) {
|
|
647
|
+
if (!HISTORY_ID_RE.test(token)) {
|
|
648
|
+
return `<history> contains a non-ID token: "${token}"`;
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
if (new Set(historyIds).size !== historyIds.length) {
|
|
652
|
+
return "<history> contains duplicate IDs";
|
|
653
|
+
}
|
|
654
|
+
const lastSeenByNamespace = new Map<string, number>();
|
|
655
|
+
for (const token of historyIds) {
|
|
656
|
+
const parts = token.match(HISTORY_ID_PARTS_RE)!;
|
|
657
|
+
const namespace = parts[1]!.replace(/-$/, "");
|
|
658
|
+
const numeric = Number(parts[3]);
|
|
659
|
+
const last = lastSeenByNamespace.get(namespace);
|
|
660
|
+
if (last !== undefined && numeric <= last) {
|
|
661
|
+
return `<history> IDs are not in per-namespace ascending numeric order (${token} after ${namespace}-${last})`;
|
|
662
|
+
}
|
|
663
|
+
lastSeenByNamespace.set(namespace, numeric);
|
|
664
|
+
}
|
|
665
|
+
return null;
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
export function evaluateV2Rules(
|
|
669
|
+
entry: Pick<CompassInventoryEntry, "path" | "keyDecisionsRequired">,
|
|
670
|
+
source: string,
|
|
671
|
+
): CompassV2Diagnostic[] {
|
|
672
|
+
const parse = parseCompassBlocks(source);
|
|
673
|
+
const diagnostics: CompassV2Diagnostic[] = [];
|
|
674
|
+
|
|
675
|
+
const orderedPositions = [
|
|
676
|
+
parse.positions.moduleContract,
|
|
677
|
+
parse.positions.keyDecisions,
|
|
678
|
+
parse.positions.changeSummary,
|
|
679
|
+
].filter((position) => position >= 0);
|
|
680
|
+
if (orderedPositions.length >= 2) {
|
|
681
|
+
const sorted = [...orderedPositions].sort((a, b) => a - b);
|
|
682
|
+
const inOrder = orderedPositions.every((position, index) => position === sorted[index]);
|
|
683
|
+
if (!inOrder) {
|
|
684
|
+
diagnostics.push({
|
|
685
|
+
ruleId: "COMPASS-ORDER-01",
|
|
686
|
+
message:
|
|
687
|
+
"Compass blocks out of canonical order (MODULE_CONTRACT → KEY_DECISIONS → CHANGE_SUMMARY)",
|
|
688
|
+
fix: "fix: reorder blocks to MODULE_CONTRACT, KEY_DECISIONS, CHANGE_SUMMARY",
|
|
689
|
+
});
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
if (entry.keyDecisionsRequired && !parse.hasKeyDecisions) {
|
|
694
|
+
diagnostics.push({
|
|
695
|
+
ruleId: "COMPASS-KD-01",
|
|
696
|
+
message: "missing KEY_DECISIONS (required for medium/high riskClass)",
|
|
697
|
+
fix: "fix: add a KEY_DECISIONS block with 1-7 current-state design items",
|
|
698
|
+
});
|
|
699
|
+
}
|
|
700
|
+
if (parse.hasKeyDecisions) {
|
|
701
|
+
const items = parse.keyDecisionsItems;
|
|
702
|
+
const realItems = items.filter((item) => item.length > 0 && !TODO_PLACEHOLDER_RE.test(item));
|
|
703
|
+
if (items.length === 0 || realItems.length === 0) {
|
|
704
|
+
diagnostics.push({
|
|
705
|
+
ruleId: "COMPASS-KD-02",
|
|
706
|
+
message: "KEY_DECISIONS block is empty or contains only TODO placeholder items",
|
|
707
|
+
fix: "fix: write real current-state decisions or remove the block",
|
|
708
|
+
});
|
|
709
|
+
}
|
|
710
|
+
for (const item of items) {
|
|
711
|
+
if (countWords(item) > KEY_DECISIONS_MAX_WORDS) {
|
|
712
|
+
diagnostics.push({
|
|
713
|
+
ruleId: "COMPASS-KD-03",
|
|
714
|
+
message: `KEY_DECISIONS item exceeds ${KEY_DECISIONS_MAX_WORDS} words`,
|
|
715
|
+
fix: "fix: compress the item to a single decision statement",
|
|
716
|
+
});
|
|
717
|
+
}
|
|
718
|
+
if (GOVERNANCE_ID_PREFIX_RE.test(item)) {
|
|
719
|
+
diagnostics.push({
|
|
720
|
+
ruleId: "COMPASS-KD-05",
|
|
721
|
+
message:
|
|
722
|
+
"KEY_DECISIONS item starts with a governance-ID prefix — history belongs in CHANGE_SUMMARY",
|
|
723
|
+
fix: "fix: rewrite the item as a current-state decision without an ID prefix",
|
|
724
|
+
});
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
if (items.length > KEY_DECISIONS_MAX_ITEMS) {
|
|
728
|
+
diagnostics.push({
|
|
729
|
+
ruleId: "COMPASS-KD-04",
|
|
730
|
+
message: `KEY_DECISIONS has ${items.length} items (cap is ${KEY_DECISIONS_MAX_ITEMS})`,
|
|
731
|
+
fix: "fix: keep only the decisions that still govern the file",
|
|
732
|
+
});
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
if (parse.purposeText.length > 0) {
|
|
737
|
+
if (PURPOSE_BOILERPLATE_PATTERNS.some((pattern) => pattern.test(parse.purposeText))) {
|
|
738
|
+
diagnostics.push({
|
|
739
|
+
ruleId: "COMPASS-PURPOSE-01",
|
|
740
|
+
message: "<purpose> matches a boilerplate pattern",
|
|
741
|
+
fix: "fix: write a specific purpose naming what this file does",
|
|
742
|
+
});
|
|
743
|
+
}
|
|
744
|
+
const tokens = deriveFileTokens(entry.path, source);
|
|
745
|
+
const purposeWords = new Set(
|
|
746
|
+
parse.purposeText
|
|
747
|
+
.toLowerCase()
|
|
748
|
+
.split(/[^a-z0-9]+/)
|
|
749
|
+
.filter(Boolean),
|
|
750
|
+
);
|
|
751
|
+
const hasToken = [...tokens].some((token) => purposeWords.has(token));
|
|
752
|
+
if (!hasToken) {
|
|
753
|
+
diagnostics.push({
|
|
754
|
+
ruleId: "COMPASS-PURPOSE-02",
|
|
755
|
+
message: "<purpose> contains no file-derived token (stem, parent dir, or exported symbol)",
|
|
756
|
+
fix: "fix: mention the file's own name or an exported symbol in <purpose>",
|
|
757
|
+
});
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
if (parse.hasChangeSummary) {
|
|
762
|
+
if (parse.changeSummaryItems.length > CHANGE_SUMMARY_MAX_ITEMS) {
|
|
763
|
+
diagnostics.push({
|
|
764
|
+
ruleId: "COMPASS-CS-05",
|
|
765
|
+
message: `CHANGE_SUMMARY has ${parse.changeSummaryItems.length} items (cap is ${CHANGE_SUMMARY_MAX_ITEMS}); collapse oldest into <history>`,
|
|
766
|
+
fix: "fix: keep the 5 newest items and collapse older IDs into <history>",
|
|
767
|
+
});
|
|
768
|
+
}
|
|
769
|
+
for (const item of parse.changeSummaryItems) {
|
|
770
|
+
if (!GOVERNANCE_ID_RE.test(item)) {
|
|
771
|
+
diagnostics.push({
|
|
772
|
+
ruleId: "COMPASS-CS-06",
|
|
773
|
+
message: `CHANGE_SUMMARY item lacks a governance-ID reference: "${item.slice(0, 60)}"`,
|
|
774
|
+
fix: "fix: prefix the item with its RFC/ADR/ticket ID or remove it",
|
|
775
|
+
});
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
if (parse.historyRaw !== null) {
|
|
779
|
+
const historyError = validateHistoryIds(parse.historyIds);
|
|
780
|
+
if (historyError) {
|
|
781
|
+
diagnostics.push({
|
|
782
|
+
ruleId: "COMPASS-CS-07",
|
|
783
|
+
message: historyError,
|
|
784
|
+
fix: "fix: <history> carries comma-separated governance IDs only, deduplicated, per-namespace ascending",
|
|
785
|
+
});
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
return diagnostics;
|
|
791
|
+
}
|
|
792
|
+
|
|
481
793
|
function detectMarkup(source: string) {
|
|
482
|
-
const
|
|
483
|
-
const hasChangeSummary = source.includes(REQUIRED_MARKERS[1]);
|
|
794
|
+
const parse = parseCompassBlocks(source);
|
|
484
795
|
const hasAiInvariant = /@ai-invariant\b/.test(source);
|
|
485
796
|
|
|
486
|
-
|
|
797
|
+
const hasPurpose = countWords(parse.purposeText) >= 10;
|
|
487
798
|
let hasNonGoals = false;
|
|
488
799
|
|
|
489
|
-
if (hasModuleContract) {
|
|
800
|
+
if (parse.hasModuleContract) {
|
|
490
801
|
const contractBlock = extractBlockContent(source, "MODULE_CONTRACT");
|
|
491
802
|
if (contractBlock) {
|
|
492
|
-
const purposeBlock = extractBlockContent(contractBlock, "purpose");
|
|
493
|
-
if (purposeBlock) {
|
|
494
|
-
const purposeText = purposeBlock.replace(/<[^>]+>/g, " ");
|
|
495
|
-
hasPurpose = countWords(purposeText) >= 10;
|
|
496
|
-
}
|
|
497
|
-
|
|
498
803
|
const nonGoalsBlock = extractBlockContent(contractBlock, "non-goals");
|
|
499
804
|
if (nonGoalsBlock) {
|
|
500
805
|
hasNonGoals = (nonGoalsBlock.match(/<item>/g) ?? []).length >= 1;
|
|
@@ -510,8 +815,12 @@ function detectMarkup(source: string) {
|
|
|
510
815
|
}
|
|
511
816
|
|
|
512
817
|
return {
|
|
513
|
-
hasModuleContract,
|
|
514
|
-
hasChangeSummary,
|
|
818
|
+
hasModuleContract: parse.hasModuleContract,
|
|
819
|
+
hasChangeSummary: parse.hasChangeSummary,
|
|
820
|
+
hasKeyDecisions: parse.hasKeyDecisions,
|
|
821
|
+
keyDecisionsItemCount: parse.keyDecisionsItems.length,
|
|
822
|
+
changeSummaryItemCount: parse.changeSummaryItems.length,
|
|
823
|
+
historyIds: parse.historyIds,
|
|
515
824
|
hasAiInvariant,
|
|
516
825
|
hasPurpose,
|
|
517
826
|
hasNonGoals,
|
|
@@ -519,6 +828,13 @@ function detectMarkup(source: string) {
|
|
|
519
828
|
};
|
|
520
829
|
}
|
|
521
830
|
|
|
831
|
+
const entrySources = new WeakMap<CompassInventoryEntry, string>();
|
|
832
|
+
|
|
833
|
+
/** Returns the source text captured during `createCompassInventoryEntries`, if still cached. */
|
|
834
|
+
export function getEntrySource(entry: CompassInventoryEntry): string | undefined {
|
|
835
|
+
return entrySources.get(entry);
|
|
836
|
+
}
|
|
837
|
+
|
|
522
838
|
export async function createCompassInventoryEntries(
|
|
523
839
|
workspaceRoot: string,
|
|
524
840
|
input: ForgeCommandInput,
|
|
@@ -546,6 +862,10 @@ export async function createCompassInventoryEntries(
|
|
|
546
862
|
);
|
|
547
863
|
const requiredScaffolding = detectRequiredScaffolding(authoringStatus);
|
|
548
864
|
const markup = detectMarkup(source);
|
|
865
|
+
const keyDecisionsRequired =
|
|
866
|
+
authoringStatus === "authored" &&
|
|
867
|
+
requiredScaffolding === "standard" &&
|
|
868
|
+
(riskClass === "medium" || riskClass === "high");
|
|
549
869
|
const nonEmptyLineCount = source.split(/\r?\n/).filter((line) => line.trim().length > 0).length;
|
|
550
870
|
const candidate: CompassInventoryEntry = {
|
|
551
871
|
path: pathFromRoot,
|
|
@@ -564,6 +884,11 @@ export async function createCompassInventoryEntries(
|
|
|
564
884
|
nonEmptyLineCount,
|
|
565
885
|
hasModuleContract: markup.hasModuleContract,
|
|
566
886
|
hasChangeSummary: markup.hasChangeSummary,
|
|
887
|
+
hasKeyDecisions: markup.hasKeyDecisions,
|
|
888
|
+
keyDecisionsItemCount: markup.keyDecisionsItemCount,
|
|
889
|
+
keyDecisionsRequired,
|
|
890
|
+
changeSummaryItemCount: markup.changeSummaryItemCount,
|
|
891
|
+
historyIds: markup.historyIds,
|
|
567
892
|
hasAiInvariant: markup.hasAiInvariant,
|
|
568
893
|
hasPurpose: markup.hasPurpose,
|
|
569
894
|
hasNonGoals: markup.hasNonGoals,
|
|
@@ -573,6 +898,7 @@ export async function createCompassInventoryEntries(
|
|
|
573
898
|
};
|
|
574
899
|
candidate.violations = detectComplianceViolations(candidate);
|
|
575
900
|
candidate.compliant = candidate.violations.length === 0;
|
|
901
|
+
entrySources.set(candidate, source);
|
|
576
902
|
entries.push(candidate);
|
|
577
903
|
}
|
|
578
904
|
|