dev-loops 0.7.1 → 0.8.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/.claude/.claude-plugin/plugin.json +1 -1
- package/.claude/agents/dev-loop.md +7 -7
- package/.claude/agents/fixer.md +1 -0
- package/.claude/agents/refiner.md +4 -4
- package/.claude/agents/review.md +10 -13
- package/.claude/commands/loop-continue.md +2 -1
- package/.claude/commands/loop-enqueue.md +9 -2
- package/.claude/commands/loop-info.md +1 -1
- package/.claude/commands/loop-start-spike.md +1 -1
- package/.claude/hooks/_bash-command-classify.mjs +7 -6
- package/.claude/hooks/_hook-decisions.mjs +5 -4
- package/.claude/skills/copilot-pr-followup/SKILL.md +42 -28
- package/.claude/skills/dev-loop/SKILL.md +8 -8
- package/.claude/skills/docs/acceptance-criteria-verification.md +13 -4
- package/.claude/skills/docs/anti-patterns.md +6 -5
- package/.claude/skills/docs/artifact-authority-contract.md +17 -14
- package/.claude/skills/docs/confirmation-rules.md +2 -1
- package/.claude/skills/docs/contract-style-guide.md +37 -0
- package/.claude/skills/docs/copilot-loop-operations.md +21 -15
- package/.claude/skills/docs/cross-harness-regression-contract.md +2 -2
- package/.claude/skills/docs/debt-remediation-contract.md +1 -1
- package/.claude/skills/docs/epic-tree-refinement-procedure.md +25 -22
- package/.claude/skills/docs/issue-intake-procedure.md +37 -22
- package/.claude/skills/docs/merge-preconditions.md +17 -13
- package/.claude/skills/docs/pr-lifecycle-contract.md +45 -36
- package/.claude/skills/docs/public-dev-loop-contract.md +61 -44
- package/.claude/skills/docs/retrospective-checkpoint-contract.md +25 -9
- package/.claude/skills/docs/spike-mode-contract.md +14 -7
- package/.claude/skills/docs/stop-conditions.md +30 -15
- package/.claude/skills/docs/tracker-first-loop-state.md +13 -8
- package/.claude/skills/docs/validation-policy.md +4 -5
- package/.claude/skills/local-implementation/SKILL.md +49 -143
- package/.claude/skills/loop-grill/SKILL.md +34 -14
- package/AGENTS.md +1 -1
- package/CHANGELOG.md +36 -0
- package/README.md +95 -189
- package/agents/dev-loop.agent.md +8 -8
- package/agents/fixer.agent.md +1 -0
- package/agents/refiner.agent.md +4 -4
- package/agents/review.agent.md +10 -13
- package/extension/README.md +5 -4
- package/package.json +7 -5
- package/scripts/_core-helpers.mjs +2 -0
- package/scripts/claude/headless-dev-loop.mjs +1 -1
- package/scripts/docs/validate-rule-ownership.mjs +442 -0
- package/scripts/docs/validate-state-machine-conformance.mjs +1169 -0
- package/scripts/github/_gate-names.mjs +5 -0
- package/scripts/github/_review-thread-mutations.mjs +5 -2
- package/scripts/github/capture-review-threads.mjs +2 -2
- package/scripts/github/create-pr.mjs +119 -10
- package/scripts/github/detect-checkpoint-evidence.mjs +109 -24
- package/scripts/github/edit-issue.mjs +259 -0
- package/scripts/github/list-review-threads.mjs +277 -0
- package/scripts/github/post-gate-findings.mjs +6 -3
- package/scripts/github/probe-ci-status.mjs +18 -0
- package/scripts/github/probe-copilot-review.mjs +24 -3
- package/scripts/github/reconcile-draft-gate.mjs +13 -13
- package/scripts/github/request-copilot-review.mjs +109 -42
- package/scripts/github/upsert-checkpoint-verdict.mjs +98 -27
- package/scripts/github/verify-briefing-prefixes.mjs +382 -0
- package/scripts/github/verify-fresh-review-context.mjs +78 -3
- package/scripts/github/wait-pr-checks.mjs +171 -0
- package/scripts/github/write-gate-context.mjs +220 -10
- package/scripts/github/write-gate-findings-log.mjs +54 -2
- package/scripts/loop/_post-convergence-change.mjs +2 -2
- package/scripts/loop/_pr-runner-coordination.mjs +112 -13
- package/scripts/loop/check-retro-tooling.mjs +14 -9
- package/scripts/loop/copilot-pr-handoff.mjs +47 -16
- package/scripts/loop/detect-change-scope.mjs +2 -2
- package/scripts/loop/detect-copilot-loop-state.mjs +34 -14
- package/scripts/loop/detect-internal-only-pr.mjs +6 -6
- package/scripts/loop/detect-pr-gate-coordination-state.mjs +152 -18
- package/scripts/loop/detect-refinement-grill-state.mjs +136 -0
- package/scripts/loop/resolve-dev-loop-startup.mjs +145 -8
- package/scripts/loop/run-watch-cycle.mjs +42 -7
- package/scripts/loop/sanctioned-commands.mjs +3 -0
- package/scripts/loop/validate-pr-body-spec.mjs +21 -5
- package/scripts/pages/build-state-atlas.mjs +65 -48
- package/scripts/projects/_resolve-project.mjs +1 -148
- package/scripts/projects/add-queue-item.mjs +87 -4
- package/scripts/projects/list-queue-items.mjs +3 -377
- package/scripts/projects/move-queue-item.mjs +3 -410
- package/scripts/projects/reorder-queue-item.mjs +3 -22
- package/skills/copilot-pr-followup/SKILL.md +42 -28
- package/skills/dev-loop/SKILL.md +3 -3
- package/skills/docs/acceptance-criteria-verification.md +13 -4
- package/skills/docs/anti-patterns.md +6 -5
- package/skills/docs/artifact-authority-contract.md +17 -14
- package/skills/docs/confirmation-rules.md +2 -1
- package/skills/docs/contract-style-guide.md +37 -0
- package/skills/docs/copilot-loop-operations.md +21 -15
- package/skills/docs/cross-harness-regression-contract.md +2 -2
- package/skills/docs/debt-remediation-contract.md +1 -1
- package/skills/docs/epic-tree-refinement-procedure.md +25 -22
- package/skills/docs/issue-intake-procedure.md +37 -22
- package/skills/docs/merge-preconditions.md +17 -13
- package/skills/docs/pr-lifecycle-contract.md +45 -36
- package/skills/docs/public-dev-loop-contract.md +61 -44
- package/skills/docs/required-rules.json +165 -0
- package/skills/docs/retrospective-checkpoint-contract.md +25 -9
- package/skills/docs/spike-mode-contract.md +14 -7
- package/skills/docs/stop-conditions.md +30 -15
- package/skills/docs/tracker-first-loop-state.md +13 -8
- package/skills/docs/validation-policy.md +4 -5
- package/skills/local-implementation/SKILL.md +49 -143
- package/skills/loop-grill/SKILL.md +38 -17
- package/scripts/docs/validate-no-duplicate-rules.mjs +0 -250
package/package.json
CHANGED
|
@@ -21,8 +21,8 @@
|
|
|
21
21
|
"packages/*"
|
|
22
22
|
],
|
|
23
23
|
"scripts": {
|
|
24
|
-
"verify": "
|
|
25
|
-
"test": "
|
|
24
|
+
"verify": "run-p --aggregate-output --continue-on-error test:assets test:extension test:scripts test:core test:docs test:pack test:dev-loop",
|
|
25
|
+
"test": "run-p --aggregate-output --continue-on-error test:assets test:extension test:scripts test:core test:docs test:pack",
|
|
26
26
|
"test:assets": "node --test --test-reporter ./test/failure-summary-reporter.mjs test/dev-loop-init-phase-smoke.test.mjs test/contracts/*.test.mjs test/workflow-handoff-contract.test.mjs",
|
|
27
27
|
"test:extension": "node --import tsx --test --test-reporter ./test/failure-summary-reporter.mjs test/extension-checks.test.mjs test/extension-post-merge-update.test.mjs test/extension-command-contract.test.mjs test/extension-package-contract.test.mjs test/extension-pi-adapter.test.mjs test/dev-loops-core.test.mjs test/dev-loops-cli.test.mjs",
|
|
28
28
|
"test:scripts": "GIT_CONFIG_COUNT=2 GIT_CONFIG_KEY_0=core.fsync GIT_CONFIG_VALUE_0=none GIT_CONFIG_KEY_1=core.fsyncObjectFiles GIT_CONFIG_VALUE_1=false node --test --test-reporter ./test/failure-summary-reporter.mjs test/github/*.test.mjs test/loop/*.test.mjs test/docs/*.test.mjs test/projects/*.test.mjs test/pages/*.test.mjs",
|
|
@@ -34,7 +34,8 @@
|
|
|
34
34
|
"test:playwright:intro-article": "PW_UI_SLICE=intro-article playwright test --project=intro-article",
|
|
35
35
|
"test:playwright:deep-dive-article": "PW_UI_SLICE=deep-dive-article playwright test --project=deep-dive-article",
|
|
36
36
|
"smoke:headless": "node scripts/claude/headless-info-smoke.mjs",
|
|
37
|
-
"test:docs": "node scripts/docs/validate-links.mjs && node scripts/docs/validate-
|
|
37
|
+
"test:docs": "node scripts/docs/validate-links.mjs && node scripts/docs/validate-rule-ownership.mjs",
|
|
38
|
+
"test:pack": "node --test --test-reporter ./test/failure-summary-reporter.mjs test/packaged-install-smoke.test.mjs",
|
|
38
39
|
"repo-wiki": "node scripts/repo-wiki.mjs",
|
|
39
40
|
"repo-wiki:bootstrap": "node scripts/repo-wiki.mjs scan --repo . && node scripts/repo-wiki.mjs plan --repo . && node scripts/repo-wiki.mjs compile --repo .",
|
|
40
41
|
"repo-wiki:scan": "node scripts/repo-wiki.mjs scan --repo .",
|
|
@@ -57,6 +58,7 @@
|
|
|
57
58
|
},
|
|
58
59
|
"devDependencies": {
|
|
59
60
|
"@playwright/test": "^1.60.0",
|
|
61
|
+
"npm-run-all2": "^9.0.2",
|
|
60
62
|
"tsx": "^4.22.0"
|
|
61
63
|
},
|
|
62
64
|
"pi": {
|
|
@@ -71,7 +73,7 @@
|
|
|
71
73
|
]
|
|
72
74
|
},
|
|
73
75
|
"dependencies": {
|
|
74
|
-
"@dev-loops/core": "^0.
|
|
76
|
+
"@dev-loops/core": "^0.8.0"
|
|
75
77
|
},
|
|
76
78
|
"repository": {
|
|
77
79
|
"type": "git",
|
|
@@ -81,7 +83,7 @@
|
|
|
81
83
|
"url": "https://github.com/mfittko/dev-loops/issues"
|
|
82
84
|
},
|
|
83
85
|
"homepage": "https://github.com/mfittko/dev-loops#readme",
|
|
84
|
-
"version": "0.
|
|
86
|
+
"version": "0.8.0",
|
|
85
87
|
"files": [
|
|
86
88
|
"cli/",
|
|
87
89
|
"lib/",
|
|
@@ -14,12 +14,14 @@ export {
|
|
|
14
14
|
} from "@dev-loops/core/loop/phase-files";
|
|
15
15
|
|
|
16
16
|
export {
|
|
17
|
+
containsBareCopilotSummon,
|
|
17
18
|
extractReviewCommitSha,
|
|
18
19
|
isCopilotLogin,
|
|
19
20
|
normalizeTimestamp,
|
|
20
21
|
parseGateReviewCommentBody,
|
|
21
22
|
parseGateReviewCommentMarkerBody,
|
|
22
23
|
resolveDraftGateRoundResetMs,
|
|
24
|
+
sanitizeCopilotSummonTokens,
|
|
23
25
|
summarizeCopilotReviews,
|
|
24
26
|
summarizeGateReviewCommentMarkers,
|
|
25
27
|
summarizeGateReviewComments,
|
|
@@ -101,7 +101,7 @@ function main(argv) {
|
|
|
101
101
|
const repoRoot = path.resolve(fileURLToPath(new URL("../../", import.meta.url)));
|
|
102
102
|
|
|
103
103
|
// --dry-run is side-effect-free: mint a run id in-memory without persisting the state file.
|
|
104
|
-
const { runId } = ensureRunId({ env: process.env, root: opts.dryRun ? undefined : repoRoot });
|
|
104
|
+
const { runId } = ensureRunId({ env: opts.dryRun ? {} : process.env, root: opts.dryRun ? undefined : repoRoot });
|
|
105
105
|
const prompt = opts.prompt ?? buildDevLoopPrompt({ issue: opts.issue, pr: opts.pr });
|
|
106
106
|
const { command, args, env } = buildHeadlessClaudeInvocation({
|
|
107
107
|
prompt,
|
|
@@ -0,0 +1,442 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* validate-rule-ownership.mjs — L0/L1 contract rule ownership validator.
|
|
4
|
+
*
|
|
5
|
+
* This scan is deliberately lexical. It catches stable rule-ID ownership,
|
|
6
|
+
* required-rule deletion, machine-checkable references, defined-term drift,
|
|
7
|
+
* RFC-2119 modality conflicts, near-duplicate rule bodies, and duplicated
|
|
8
|
+
* free-text imperative sentences across the corpus. All findings are gating
|
|
9
|
+
* (exit 1). Behavioral contradictions belong to the L2/L3 harness; semantic
|
|
10
|
+
* contradictions belong to the gate contradiction lens.
|
|
11
|
+
*
|
|
12
|
+
* Also gates corpus→manifest completeness: every defined rule must be listed
|
|
13
|
+
* in required-rules.json's requiredRules or its optOutRules (unregistered_rule),
|
|
14
|
+
* the optOutRules manifest itself must be internally consistent (no ID in both
|
|
15
|
+
* lists — conflicting_manifest_entry; no opt-out for an undefined rule —
|
|
16
|
+
* dead_opt_out_entry), and the duplicate-sentence allowlist must not contain
|
|
17
|
+
* stale entries (dead_allowlist_entry).
|
|
18
|
+
*
|
|
19
|
+
* Subsumes former scripts/docs/validate-no-duplicate-rules.mjs (retired):
|
|
20
|
+
* the duplicate-imperative-sentence scan below ports its unique check,
|
|
21
|
+
* widened from skills/ only to every SOURCE_ROOTS directory.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { readFile, readdir } from "node:fs/promises";
|
|
25
|
+
import path from "node:path";
|
|
26
|
+
import { fileURLToPath } from "node:url";
|
|
27
|
+
|
|
28
|
+
import { isDirectCliRun, parseJsonText } from "../_core-helpers.mjs";
|
|
29
|
+
|
|
30
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
31
|
+
const REPO_ROOT = path.resolve(__dirname, "..", "..");
|
|
32
|
+
|
|
33
|
+
const SOURCE_ROOTS = ["skills", "agents", "commands", "docs"];
|
|
34
|
+
const MARKER_RE = /<!--\s*rule:\s*([A-Z][A-Z0-9-]*)\s*-->/g;
|
|
35
|
+
const REF_RE = /<!--\s*rule-ref:\s*([A-Z][A-Z0-9-]*)\s*-->|\[([A-Z0-9]+(?:-[A-Z0-9]+){2,})\]\([^)]*\)/g;
|
|
36
|
+
const TERM_RE = /<!--\s*term:\s*(state|reason|gate):([a-zA-Z0-9_.:-]+)\s*-->/g;
|
|
37
|
+
const CODE_TOKEN_RE = /`([a-z][a-z0-9_:-]+)`/g;
|
|
38
|
+
const MODAL_RE = /\b(MUST NOT|SHALL NOT|SHOULD NOT|MAY NOT|MUST|SHALL|SHOULD|MAY)\b/g;
|
|
39
|
+
|
|
40
|
+
// Duplicate-imperative-sentence scan (ported from validate-no-duplicate-rules.mjs).
|
|
41
|
+
const IMPERATIVE_PATTERNS = [/\bmust\b/i, /\bnever\b/i, /\bdo not\b/i, /\brequire[sd]?\b/i];
|
|
42
|
+
const MIN_SENTENCE_LENGTH = 20;
|
|
43
|
+
|
|
44
|
+
// Contract docs that mirror each other's content by design.
|
|
45
|
+
const CANONICAL_MIRROR_DOCS = new Set(["skills/docs/copilot-loop-operations.md", "skills/docs/public-dev-loop-contract.md"]);
|
|
46
|
+
|
|
47
|
+
// Sentences deliberately duplicated across files (mirrored procedure text, or
|
|
48
|
+
// command trigger-phrasing that STYLE/(c) guardrails require to stay verbatim
|
|
49
|
+
// per file). Cross-file duplication of these is not a corpus authoring bug.
|
|
50
|
+
const KNOWN_INTENTIONAL_DUPLICATE_SENTENCES = new Set([
|
|
51
|
+
// agents/dev-loop.agent.md mirrors this CLI-fallback safety line from skills/dev-loop/SKILL.md;
|
|
52
|
+
// each doc's surrounding candidate-resolution list differs per install layout.
|
|
53
|
+
"NEVER fall back to or any unbounded filesystem walk to locate the CLI — it stalls and trips the needs-attention timeout.",
|
|
54
|
+
// commands/loop-auto.command.md and commands/loop-start.command.md — command trigger
|
|
55
|
+
// phrasing is load-bearing verbatim per-command guardrail (c); not a restatement bug.
|
|
56
|
+
"Do not pick an internal strategy name yourself.",
|
|
57
|
+
// docs/slides-story-review-loop.md and docs/ui-designer-review-loop.md are sibling
|
|
58
|
+
// non-normative review-loop docs (epic non-goal) that share boilerplate by design.
|
|
59
|
+
"The loop requires all of the following inputs before it may run:",
|
|
60
|
+
"If any required part of this bundle is missing, incomplete, or ambiguous, the loop fails closed instead of guessing.",
|
|
61
|
+
"- required acceptance criteria are missing",
|
|
62
|
+
]);
|
|
63
|
+
|
|
64
|
+
const USAGE = `Usage: validate-rule-ownership.mjs [--help]
|
|
65
|
+
|
|
66
|
+
Validate rule markers, required IDs, rule references, term definitions,
|
|
67
|
+
near-duplicate/modality-conflict findings, and duplicated imperative
|
|
68
|
+
sentences. Also validates corpus→manifest completeness (every defined rule
|
|
69
|
+
must be in requiredRules or explicitly opted out via optOutRules in
|
|
70
|
+
required-rules.json), optOutRules manifest hygiene (no rule listed as both
|
|
71
|
+
required and opted-out; no opt-out for an undefined rule), and that the
|
|
72
|
+
duplicate-sentence allowlist has no stale (dead) entries. All findings are
|
|
73
|
+
gating (exit 1).
|
|
74
|
+
|
|
75
|
+
Options:
|
|
76
|
+
--help, -h Show this help`.trim();
|
|
77
|
+
|
|
78
|
+
export function isImperativeSentence(sentence) {
|
|
79
|
+
return IMPERATIVE_PATTERNS.some((pattern) => pattern.test(sentence));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function normalizeSentence(text) {
|
|
83
|
+
return text.replace(/\s+/g, " ").trim();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function extractImperativeSentences(content) {
|
|
87
|
+
const lines = content.split(/\r?\n/);
|
|
88
|
+
const sentences = [];
|
|
89
|
+
let inFencedBlock = false;
|
|
90
|
+
let fencedDelimiter = "";
|
|
91
|
+
|
|
92
|
+
for (let i = 0; i < lines.length; i++) {
|
|
93
|
+
let line = lines[i];
|
|
94
|
+
const rawTrimmed = line.trim();
|
|
95
|
+
|
|
96
|
+
if (/^\s*#/.test(line) || /^\s*>/.test(line)) continue;
|
|
97
|
+
|
|
98
|
+
const fenceMatch = rawTrimmed.match(/^(```|~~~)/);
|
|
99
|
+
if (fenceMatch) {
|
|
100
|
+
if (!inFencedBlock) {
|
|
101
|
+
inFencedBlock = true;
|
|
102
|
+
fencedDelimiter = fenceMatch[1];
|
|
103
|
+
continue;
|
|
104
|
+
} else if (rawTrimmed.startsWith(fencedDelimiter)) {
|
|
105
|
+
inFencedBlock = false;
|
|
106
|
+
fencedDelimiter = "";
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
if (inFencedBlock) continue;
|
|
111
|
+
|
|
112
|
+
line = line.replace(/`[^`]*`/g, "").replace(/\[([^\]]*)\]\([^)]*\)/g, "$1");
|
|
113
|
+
|
|
114
|
+
const parts = line.split(/(?<=[.!?])\s+(?=[A-Z])/);
|
|
115
|
+
for (const part of parts) {
|
|
116
|
+
const normalized = normalizeSentence(part);
|
|
117
|
+
if (normalized.length >= MIN_SENTENCE_LENGTH && isImperativeSentence(normalized)) {
|
|
118
|
+
sentences.push({ text: normalized, line: i + 1 });
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return sentences;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function detectDuplicateImperativeSentences(fileContents) {
|
|
126
|
+
const bySentence = new Map();
|
|
127
|
+
for (const { file, content } of fileContents) {
|
|
128
|
+
for (const { text, line } of extractImperativeSentences(content)) {
|
|
129
|
+
if (!bySentence.has(text)) bySentence.set(text, []);
|
|
130
|
+
bySentence.get(text).push({ file, line });
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
const findings = [];
|
|
134
|
+
for (const [text, occurrences] of bySentence) {
|
|
135
|
+
if (KNOWN_INTENTIONAL_DUPLICATE_SENTENCES.has(text)) continue;
|
|
136
|
+
const files = new Set(occurrences.map((o) => o.file));
|
|
137
|
+
if (files.size <= 1) continue;
|
|
138
|
+
if ([...files].every((f) => CANONICAL_MIRROR_DOCS.has(f))) continue;
|
|
139
|
+
findings.push({ kind: "duplicate_imperative_sentence", text, occurrences });
|
|
140
|
+
}
|
|
141
|
+
return findings;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function toPosix(p) {
|
|
145
|
+
return p.replace(/\\/g, "/");
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async function* walk(dir) {
|
|
149
|
+
let entries;
|
|
150
|
+
try {
|
|
151
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
152
|
+
} catch (err) {
|
|
153
|
+
if (err?.code === "ENOENT") return;
|
|
154
|
+
throw err;
|
|
155
|
+
}
|
|
156
|
+
for (const entry of entries) {
|
|
157
|
+
if (entry.name === "node_modules" || entry.name === "tmp" || entry.name === "site" || entry.name === ".claude") {
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
const full = path.join(dir, entry.name);
|
|
161
|
+
if (entry.isSymbolicLink()) continue;
|
|
162
|
+
if (entry.isDirectory()) yield* walk(full);
|
|
163
|
+
else if (entry.isFile() && entry.name.endsWith(".md")) yield full;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export async function collectContractMarkdownFiles(repoRoot = REPO_ROOT) {
|
|
168
|
+
const files = [];
|
|
169
|
+
for (const root of SOURCE_ROOTS) {
|
|
170
|
+
for await (const file of walk(path.join(repoRoot, root))) {
|
|
171
|
+
files.push(file);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return files.sort();
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function lineFor(content, index) {
|
|
178
|
+
return content.slice(0, index).split(/\r?\n/).length;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function stripFences(content) {
|
|
182
|
+
const lines = content.split(/\r?\n/);
|
|
183
|
+
let inFence = false;
|
|
184
|
+
let fence = "";
|
|
185
|
+
return lines.map((line) => {
|
|
186
|
+
const m = line.trim().match(/^(```|~~~)/);
|
|
187
|
+
if (m) {
|
|
188
|
+
if (!inFence) {
|
|
189
|
+
inFence = true;
|
|
190
|
+
fence = m[1];
|
|
191
|
+
} else if (line.trim().startsWith(fence)) {
|
|
192
|
+
inFence = false;
|
|
193
|
+
fence = "";
|
|
194
|
+
}
|
|
195
|
+
return "";
|
|
196
|
+
}
|
|
197
|
+
return inFence ? "" : line;
|
|
198
|
+
}).join("\n");
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function extractRuleBody(content, markerIndex) {
|
|
202
|
+
const after = content.slice(markerIndex).replace(MARKER_RE, "").trimStart();
|
|
203
|
+
const firstLine = after.split(/\r?\n/, 1)[0].trim();
|
|
204
|
+
return firstLine.replace(/^`[A-Z][A-Z0-9-]*`\s*\|\s*/, "").replace(/\s+/g, " ").trim();
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export function extractRuleDefinitions(content, file) {
|
|
208
|
+
const defs = [];
|
|
209
|
+
const scan = stripFences(content);
|
|
210
|
+
for (const match of scan.matchAll(MARKER_RE)) {
|
|
211
|
+
defs.push({ id: match[1], file, line: lineFor(scan, match.index), body: extractRuleBody(scan, match.index) });
|
|
212
|
+
}
|
|
213
|
+
return defs;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export function extractRuleReferences(content, file) {
|
|
217
|
+
const refs = [];
|
|
218
|
+
const scan = stripFences(content);
|
|
219
|
+
for (const match of scan.matchAll(REF_RE)) {
|
|
220
|
+
const id = match[1] || match[2];
|
|
221
|
+
refs.push({ id, file, line: lineFor(scan, match.index) });
|
|
222
|
+
}
|
|
223
|
+
return refs;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export function extractTermDefinitions(content, file) {
|
|
227
|
+
const terms = [];
|
|
228
|
+
const scan = stripFences(content);
|
|
229
|
+
for (const match of scan.matchAll(TERM_RE)) {
|
|
230
|
+
terms.push({ kind: match[1], value: match[2], key: `${match[1]}:${match[2]}`, file, line: lineFor(scan, match.index) });
|
|
231
|
+
}
|
|
232
|
+
return terms;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export function extractTermUses(content, file) {
|
|
236
|
+
const terms = extractTermDefinitions(content, file);
|
|
237
|
+
if (terms.length === 0 && !/<!--\s*rule:\s*[A-Z][A-Z0-9-]*\s*-->/u.test(stripFences(content))) return [];
|
|
238
|
+
const defs = new Set(terms.map((t) => t.value));
|
|
239
|
+
const uses = [];
|
|
240
|
+
const scan = stripFences(content);
|
|
241
|
+
for (const match of scan.matchAll(CODE_TOKEN_RE)) {
|
|
242
|
+
const token = match[1];
|
|
243
|
+
if (defs.has(token) || /^(npm|node|gh|dev-loop|dev-loops|final_approval|wait_watch|issue_intake|copilot_pr_followup|autonomy|humanMergeOnly)$/.test(token)) {
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
if (/^(blocked|done|approval_ready|merge_ready|waiting|waiting_for_[a-z0-9_]+|needs_reconcile|[a-z]+_[a-z0-9_]*_gate)$/.test(token)) {
|
|
247
|
+
uses.push({ token, file, line: lineFor(scan, match.index) });
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
return uses;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export function detectNearDuplicates(definitions) {
|
|
254
|
+
const seen = new Map();
|
|
255
|
+
const findings = [];
|
|
256
|
+
for (const def of definitions) {
|
|
257
|
+
const normalized = def.body.toLowerCase().replace(/`[^`]+`/g, "").replace(/\b(must|shall|should|may|not)\b/g, "").replace(/[^a-z0-9]+/g, " ").trim();
|
|
258
|
+
if (!normalized || normalized.length < 24) continue;
|
|
259
|
+
const prior = seen.get(normalized);
|
|
260
|
+
if (prior && prior.id !== def.id) findings.push({ kind: "near_duplicate", a: prior, b: def });
|
|
261
|
+
else seen.set(normalized, def);
|
|
262
|
+
}
|
|
263
|
+
return findings;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export function detectModalityConflicts(definitions) {
|
|
267
|
+
// Order-insensitive: group every definition per normalized subject first,
|
|
268
|
+
// then compare all pairs, so file-walk order can never mask a conflict
|
|
269
|
+
// (this scan gates the build).
|
|
270
|
+
const bySubject = new Map();
|
|
271
|
+
for (const def of definitions) {
|
|
272
|
+
const modalities = [...def.body.matchAll(MODAL_RE)].map((m) => m[1]);
|
|
273
|
+
if (modalities.length === 0) continue;
|
|
274
|
+
const subject = def.body
|
|
275
|
+
.replace(MODAL_RE, "")
|
|
276
|
+
.replace(/`[^`]+`/g, "")
|
|
277
|
+
.replace(/[^a-zA-Z0-9]+/g, " ")
|
|
278
|
+
.toLowerCase()
|
|
279
|
+
.trim();
|
|
280
|
+
if (!subject) continue;
|
|
281
|
+
if (!bySubject.has(subject)) bySubject.set(subject, []);
|
|
282
|
+
bySubject.get(subject).push({ def, modalities });
|
|
283
|
+
}
|
|
284
|
+
const findings = [];
|
|
285
|
+
for (const entries of bySubject.values()) {
|
|
286
|
+
for (let i = 0; i < entries.length; i += 1) {
|
|
287
|
+
for (let j = i + 1; j < entries.length; j += 1) {
|
|
288
|
+
const a = entries[i];
|
|
289
|
+
const b = entries[j];
|
|
290
|
+
const aNegative = a.modalities.some((m) => m.endsWith("NOT"));
|
|
291
|
+
const bNegative = b.modalities.some((m) => m.endsWith("NOT"));
|
|
292
|
+
// MUST and SHALL are equivalent strong forms (RFC 2119); either
|
|
293
|
+
// downgrading to SHOULD/MAY (or the negative equivalents) flags.
|
|
294
|
+
const strongPositive = (mods) => mods.some((m) => m === "MUST" || m === "SHALL");
|
|
295
|
+
const strongNegative = (mods) => mods.some((m) => m === "MUST NOT" || m === "SHALL NOT");
|
|
296
|
+
const weakPositive = (mods) => mods.some((m) => m === "SHOULD" || m === "MAY");
|
|
297
|
+
const weakNegative = (mods) => mods.some((m) => m === "SHOULD NOT" || m === "MAY NOT");
|
|
298
|
+
const weaker = (strongPositive(a.modalities) && weakPositive(b.modalities))
|
|
299
|
+
|| (strongPositive(b.modalities) && weakPositive(a.modalities))
|
|
300
|
+
|| (strongNegative(a.modalities) && weakNegative(b.modalities))
|
|
301
|
+
|| (strongNegative(b.modalities) && weakNegative(a.modalities));
|
|
302
|
+
if (aNegative !== bNegative || weaker) findings.push({ kind: "modality_conflict", a: a.def, b: b.def });
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
return findings;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
async function readRequiredRules(repoRoot) {
|
|
310
|
+
const rulesPath = path.join(repoRoot, "skills", "docs", "required-rules.json");
|
|
311
|
+
const text = await readFile(rulesPath, "utf8");
|
|
312
|
+
let parsed;
|
|
313
|
+
try {
|
|
314
|
+
parsed = parseJsonText(text);
|
|
315
|
+
} catch (error) {
|
|
316
|
+
throw new Error(`Malformed JSON in ${rulesPath}`, { cause: error });
|
|
317
|
+
}
|
|
318
|
+
return {
|
|
319
|
+
requiredRules: Array.isArray(parsed.requiredRules) ? parsed.requiredRules : [],
|
|
320
|
+
// Explicit opt-out for a defined rule that is intentionally NOT deletion-protected. Empty by default.
|
|
321
|
+
optOutRules: Array.isArray(parsed.optOutRules) ? parsed.optOutRules : [],
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// A KNOWN_INTENTIONAL_DUPLICATE_SENTENCES entry is "dead" when it no longer suppresses a real
|
|
326
|
+
// cross-file duplicate: the sentence would not be flagged even without the allowlist (absent,
|
|
327
|
+
// single-file, or only across canonical-mirror docs). A dead entry silently pre-authorizes
|
|
328
|
+
// reintroducing that exact duplicate, so it gates.
|
|
329
|
+
export function detectDeadAllowlistEntries(fileContents, allowlist = KNOWN_INTENTIONAL_DUPLICATE_SENTENCES) {
|
|
330
|
+
const filesBySentence = new Map();
|
|
331
|
+
for (const { file, content } of fileContents) {
|
|
332
|
+
for (const { text } of extractImperativeSentences(content)) {
|
|
333
|
+
if (!filesBySentence.has(text)) filesBySentence.set(text, new Set());
|
|
334
|
+
filesBySentence.get(text).add(file);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
const dead = [];
|
|
338
|
+
for (const text of allowlist) {
|
|
339
|
+
const files = filesBySentence.get(text);
|
|
340
|
+
const wouldFlag = files && files.size > 1 && ![...files].every((f) => CANONICAL_MIRROR_DOCS.has(f));
|
|
341
|
+
if (!wouldFlag) dead.push(text);
|
|
342
|
+
}
|
|
343
|
+
return dead;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
export async function validateRuleOwnership(repoRoot = REPO_ROOT) {
|
|
347
|
+
const files = await collectContractMarkdownFiles(repoRoot);
|
|
348
|
+
const definitions = [];
|
|
349
|
+
const references = [];
|
|
350
|
+
const termDefs = [];
|
|
351
|
+
const termUses = [];
|
|
352
|
+
const fileContents = [];
|
|
353
|
+
|
|
354
|
+
for (const filePath of files) {
|
|
355
|
+
const rel = toPosix(path.relative(repoRoot, filePath));
|
|
356
|
+
const content = await readFile(filePath, "utf8");
|
|
357
|
+
fileContents.push({ file: rel, content });
|
|
358
|
+
definitions.push(...extractRuleDefinitions(content, rel));
|
|
359
|
+
references.push(...extractRuleReferences(content, rel));
|
|
360
|
+
termDefs.push(...extractTermDefinitions(content, rel));
|
|
361
|
+
termUses.push(...extractTermUses(content, rel));
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const errors = [];
|
|
365
|
+
const byId = new Map();
|
|
366
|
+
for (const def of definitions) {
|
|
367
|
+
if (!byId.has(def.id)) byId.set(def.id, []);
|
|
368
|
+
byId.get(def.id).push(def);
|
|
369
|
+
}
|
|
370
|
+
for (const [id, defs] of byId) {
|
|
371
|
+
if (defs.length !== 1) errors.push({ kind: "duplicate_rule_definition", id, locations: defs.map(({ file, line }) => `${file}:${line}`) });
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
const { requiredRules, optOutRules } = await readRequiredRules(repoRoot);
|
|
375
|
+
const requiredSet = new Set(requiredRules);
|
|
376
|
+
const optOutSet = new Set(optOutRules);
|
|
377
|
+
for (const id of requiredRules) {
|
|
378
|
+
if (!byId.has(id)) errors.push({ kind: "required_rule_missing", id });
|
|
379
|
+
}
|
|
380
|
+
// corpus→manifest completeness: every defined rule must be registered in the manifest or explicitly opted out.
|
|
381
|
+
for (const [id, defs] of byId) {
|
|
382
|
+
if (!requiredSet.has(id) && !optOutSet.has(id)) {
|
|
383
|
+
errors.push({ kind: "unregistered_rule", id, location: defs.map(({ file, line }) => `${file}:${line}`).join(", ") });
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
// optOutRules manifest hygiene, in file order for deterministic error ordering.
|
|
387
|
+
for (const id of optOutRules) {
|
|
388
|
+
if (requiredSet.has(id)) errors.push({ kind: "conflicting_manifest_entry", id });
|
|
389
|
+
if (!byId.has(id)) errors.push({ kind: "dead_opt_out_entry", id });
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
for (const ref of references) {
|
|
393
|
+
if (!byId.has(ref.id)) errors.push({ kind: "unresolved_rule_reference", id: ref.id, location: `${ref.file}:${ref.line}` });
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
const termsByKey = new Map();
|
|
397
|
+
for (const term of termDefs) {
|
|
398
|
+
if (!termsByKey.has(term.key)) termsByKey.set(term.key, []);
|
|
399
|
+
termsByKey.get(term.key).push(term);
|
|
400
|
+
}
|
|
401
|
+
for (const [key, defs] of termsByKey) {
|
|
402
|
+
if (defs.length !== 1) errors.push({ kind: "duplicate_term_definition", key, locations: defs.map(({ file, line }) => `${file}:${line}`) });
|
|
403
|
+
}
|
|
404
|
+
const termValues = new Set(termDefs.map((t) => t.value));
|
|
405
|
+
for (const use of termUses) {
|
|
406
|
+
if (!termValues.has(use.token)) errors.push({ kind: "undefined_term_use", token: use.token, location: `${use.file}:${use.line}` });
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
for (const finding of detectNearDuplicates(definitions)) {
|
|
410
|
+
errors.push({ kind: finding.kind, id: `${finding.a.id}~${finding.b.id}`, location: `${finding.a.file}:${finding.a.line} / ${finding.b.file}:${finding.b.line}` });
|
|
411
|
+
}
|
|
412
|
+
for (const finding of detectModalityConflicts(definitions)) {
|
|
413
|
+
errors.push({ kind: finding.kind, id: `${finding.a.id}~${finding.b.id}`, location: `${finding.a.file}:${finding.a.line} / ${finding.b.file}:${finding.b.line}` });
|
|
414
|
+
}
|
|
415
|
+
for (const finding of detectDuplicateImperativeSentences(fileContents)) {
|
|
416
|
+
errors.push({ kind: finding.kind, id: finding.text, location: finding.occurrences.map((o) => `${o.file}:${o.line}`).join(", ") });
|
|
417
|
+
}
|
|
418
|
+
for (const text of detectDeadAllowlistEntries(fileContents)) {
|
|
419
|
+
errors.push({ kind: "dead_allowlist_entry", id: text });
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
return { ok: errors.length === 0, filesScanned: files.length, rules: definitions.length, references: references.length, terms: termDefs.length, errors };
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
async function main(argv = process.argv.slice(2)) {
|
|
426
|
+
if (argv.includes("--help") || argv.includes("-h")) {
|
|
427
|
+
process.stdout.write(`${USAGE}\n`);
|
|
428
|
+
return 0;
|
|
429
|
+
}
|
|
430
|
+
const result = await validateRuleOwnership(REPO_ROOT);
|
|
431
|
+
if (result.ok) {
|
|
432
|
+
process.stdout.write(`Rule ownership validation passed: ${result.rules} rules, ${result.references} references, ${result.terms} terms, ${result.filesScanned} files scanned.\n`);
|
|
433
|
+
return 0;
|
|
434
|
+
}
|
|
435
|
+
process.stdout.write(`Rule ownership validation failed:\n`);
|
|
436
|
+
for (const error of result.errors) process.stdout.write(`- ${error.kind}: ${error.id || error.key || error.token} ${error.location || (error.locations || []).join(", ")}\n`);
|
|
437
|
+
return 1;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
if (isDirectCliRun(import.meta.url)) {
|
|
441
|
+
process.exitCode = await main();
|
|
442
|
+
}
|