@forwardimpact/libwiki 0.2.27 → 0.2.29
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/package.json +1 -1
- package/src/audit/rule-builders.js +247 -0
- package/src/audit/rules.js +72 -196
- package/src/audit/scopes.js +21 -1
- package/src/boot.js +79 -19
- package/src/cli-definition.js +27 -0
- package/src/commands/claim.js +59 -22
- package/src/commands/product-mix.js +148 -0
- package/src/commands/refresh.js +37 -2
- package/src/commands/sync.js +10 -3
- package/src/constants.js +29 -0
- package/src/issue-list-renderer.js +71 -0
- package/src/marker-scanner.js +18 -1
- package/src/sanitize.js +53 -0
- package/src/wiki-sync.js +573 -56
package/package.json
CHANGED
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ACTIVE_CLAIMS_HEADER_RE,
|
|
3
|
+
ACTIVE_CLAIMS_HEADING,
|
|
4
|
+
CARRY_CLEARANCE_MARKER_RE,
|
|
5
|
+
CARRY_SURFACE_H1_RE,
|
|
6
|
+
PRIORITY_INDEX_HEADING,
|
|
7
|
+
WEEKLY_LOG_SEAM_RE,
|
|
8
|
+
} from "../constants.js";
|
|
9
|
+
import {
|
|
10
|
+
PRIORITY_HEADER_RE,
|
|
11
|
+
SUMMARY_H1_RE,
|
|
12
|
+
WEEKLY_LOG_H1_RE,
|
|
13
|
+
} from "./scopes.js";
|
|
14
|
+
|
|
15
|
+
// Check builders and the derived matchers they share, extracted from rules.js
|
|
16
|
+
// so the rule table stays under the per-file line cap. Each builder takes a
|
|
17
|
+
// subject (plus optional ctx) and returns null | finding | finding[]. rules.js
|
|
18
|
+
// imports exactly the symbols its rule table references; the pure constants in
|
|
19
|
+
// constants.js/scopes.js it still imports straight from source.
|
|
20
|
+
|
|
21
|
+
export const PRIORITY_INDEX_HEADING_RE = new RegExp(
|
|
22
|
+
`^${PRIORITY_INDEX_HEADING}$`,
|
|
23
|
+
"m",
|
|
24
|
+
);
|
|
25
|
+
const ACTIVE_CLAIMS_HEADING_RE = new RegExp(`^${ACTIVE_CLAIMS_HEADING}$`, "m");
|
|
26
|
+
export const PRIORITY_SEPARATOR_RE =
|
|
27
|
+
/^\|\s*---\s*\|\s*---\s*\|\s*---\s*\|\s*---\s*\|\s*---\s*\|/m;
|
|
28
|
+
export const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
|
29
|
+
|
|
30
|
+
// improvement-coach is the storyboard facilitator and carries no domain
|
|
31
|
+
// metrics; only the five domain agents need their own H3.
|
|
32
|
+
const STORYBOARD_DOMAIN_AGENTS = [
|
|
33
|
+
"product-manager",
|
|
34
|
+
"release-engineer",
|
|
35
|
+
"security-engineer",
|
|
36
|
+
"staff-engineer",
|
|
37
|
+
"technical-writer",
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
// -- Check builders: subject (+ ctx) → null | finding | finding[] --
|
|
41
|
+
|
|
42
|
+
export const matches = (pattern) => (s) => (pattern.test(s.text) ? null : {});
|
|
43
|
+
export const firstLineMatches = (pattern) => (s) =>
|
|
44
|
+
pattern.test(s.firstLine) ? null : {};
|
|
45
|
+
export const containsLine = (needle) => (s) =>
|
|
46
|
+
s.fileLines.some((l) => l.trim() === needle) ? null : {};
|
|
47
|
+
|
|
48
|
+
export const lineBudget = (limit) => (s) =>
|
|
49
|
+
s.lines > limit ? { value: s.lines } : null;
|
|
50
|
+
export const wordBudget = (limit) => (s) =>
|
|
51
|
+
s.words > limit ? { value: s.words } : null;
|
|
52
|
+
|
|
53
|
+
export const firstH2Is = (expected) => (s) =>
|
|
54
|
+
s.h2s.length === 0 || s.h2s[0] === expected ? null : { observed: s.h2s[0] };
|
|
55
|
+
|
|
56
|
+
export const nothingAfterH2 = (marker) => (s) => {
|
|
57
|
+
const idx = s.h2s.indexOf(marker);
|
|
58
|
+
if (idx === -1) return null;
|
|
59
|
+
const after = s.h2s.slice(idx + 1);
|
|
60
|
+
return after.length === 0 ? null : after.map((h) => ({ observed: h }));
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
export const fieldMatches = (name, pattern) => (s) =>
|
|
64
|
+
pattern.test(s[name]) ? null : { value: s[name] };
|
|
65
|
+
|
|
66
|
+
export const columnCount = (expected) => (s) =>
|
|
67
|
+
s.cells.length === expected ? null : { actual: s.cells.length, expected };
|
|
68
|
+
|
|
69
|
+
export const exists = (s) => (s.exists ? null : {});
|
|
70
|
+
export const expired = (s, ctx) => (s.expires_at < ctx.today ? {} : null);
|
|
71
|
+
|
|
72
|
+
// The heading must equal `requiredLine` exactly — a suffixed variant like
|
|
73
|
+
// "### Decision — <summary>" does not satisfy it, but is reported as a
|
|
74
|
+
// near miss so the writer fixes the heading instead of hunting for a
|
|
75
|
+
// "missing" line that is right there.
|
|
76
|
+
function entryHasDecision(lines, startIdx, requiredLine, stopRe) {
|
|
77
|
+
let seen = 0;
|
|
78
|
+
let nearMiss = null;
|
|
79
|
+
for (let j = startIdx + 1; j < lines.length && seen < 5; j++) {
|
|
80
|
+
const ln = lines[j].trim();
|
|
81
|
+
if (ln === "") continue;
|
|
82
|
+
seen++;
|
|
83
|
+
if (ln === requiredLine) return { found: true };
|
|
84
|
+
if (nearMiss === null && ln.startsWith(requiredLine)) nearMiss = ln;
|
|
85
|
+
if (stopRe.test(lines[j])) break;
|
|
86
|
+
}
|
|
87
|
+
return { found: false, nearMiss };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export const decisionWithin5 =
|
|
91
|
+
({ entryRe, requiredLine, stopRe }) =>
|
|
92
|
+
(s) => {
|
|
93
|
+
const offenders = [];
|
|
94
|
+
for (let i = 0; i < s.fileLines.length; i++) {
|
|
95
|
+
if (!entryRe.test(s.fileLines[i])) continue;
|
|
96
|
+
const res = entryHasDecision(s.fileLines, i, requiredLine, stopRe);
|
|
97
|
+
if (!res.found) offenders.push({ lineNo: i + 1, nearMiss: res.nearMiss });
|
|
98
|
+
}
|
|
99
|
+
return offenders.length === 0 ? null : offenders;
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
// Flag entry-shaped `## ` headings that the rotation seam-finder would skip —
|
|
103
|
+
// the grammar-drift that degrades a whole file to one unsplittable prologue and
|
|
104
|
+
// is otherwise silent (the decision-block rule matches only dated headings).
|
|
105
|
+
// Uses the same WEEKLY_LOG_SEAM_RE the seam-finder uses, so the flagged set is
|
|
106
|
+
// exactly the complement of the rotatable set.
|
|
107
|
+
export const headingGrammarDrift = (s) => {
|
|
108
|
+
const offenders = [];
|
|
109
|
+
for (let i = 0; i < s.fileLines.length; i++) {
|
|
110
|
+
const line = s.fileLines[i];
|
|
111
|
+
if (/^## /.test(line) && !WEEKLY_LOG_SEAM_RE.test(line)) {
|
|
112
|
+
offenders.push({ lineNo: i + 1, observed: line.trim() });
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return offenders.length === 0 ? null : offenders;
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
function scanMarkers(fileLines, openRe, closeRe, label) {
|
|
119
|
+
const openings = [];
|
|
120
|
+
const findings = [];
|
|
121
|
+
for (let i = 0; i < fileLines.length; i++) {
|
|
122
|
+
const openMatch = fileLines[i].match(openRe);
|
|
123
|
+
if (openMatch) {
|
|
124
|
+
openings.push({ label: openMatch[1] || label, lineNo: i + 1 });
|
|
125
|
+
} else if (closeRe.test(fileLines[i])) {
|
|
126
|
+
if (openings.length > 0) openings.pop();
|
|
127
|
+
else findings.push({ lineNo: i + 1, reason: "unpaired-close" });
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
for (const open of openings) {
|
|
131
|
+
findings.push({
|
|
132
|
+
lineNo: open.lineNo,
|
|
133
|
+
reason: "dangling-open",
|
|
134
|
+
label: open.label,
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
return findings;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export const markersBalanced =
|
|
141
|
+
({ openRe, closeRe, label }) =>
|
|
142
|
+
(s) => {
|
|
143
|
+
const findings = scanMarkers(s.fileLines, openRe, closeRe, label);
|
|
144
|
+
return findings.length === 0 ? null : findings;
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
export const allRequiredLines = (required) => (s) => {
|
|
148
|
+
const findings = [];
|
|
149
|
+
for (const r of required) {
|
|
150
|
+
if (!s.fileLines.some((l) => r.pattern.test(l))) {
|
|
151
|
+
findings.push({ label: r.label });
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return findings.length === 0 ? null : findings;
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
// -- H1 → filename agent prefix --
|
|
158
|
+
|
|
159
|
+
const slugify = (title) =>
|
|
160
|
+
title
|
|
161
|
+
.trim()
|
|
162
|
+
.toLowerCase()
|
|
163
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
164
|
+
.replace(/(^-|-$)/g, "");
|
|
165
|
+
|
|
166
|
+
export const summaryAgentMismatch = (s) => {
|
|
167
|
+
const titleSlug = slugify(s.firstLine.match(SUMMARY_H1_RE)[1]);
|
|
168
|
+
return titleSlug === s.agentPrefix ? null : { titleSlug };
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
export const weeklyAgentMismatch = (s) => {
|
|
172
|
+
const m = s.firstLine.match(WEEKLY_LOG_H1_RE);
|
|
173
|
+
if (!m) return null;
|
|
174
|
+
const titleSlug = slugify(m[1]);
|
|
175
|
+
return titleSlug === s.agentPrefix ? null : { titleSlug };
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
// Carry surface: the H1 agent slug (`# <agent> — Carries`) must agree with the
|
|
179
|
+
// filename prefix (`<agent>-carries.md`), the carry analogue of the summary /
|
|
180
|
+
// weekly-log agreement rules. The H1 is the slug form already, so slugify is a
|
|
181
|
+
// no-op for well-formed files; it normalises a stray capitalisation otherwise.
|
|
182
|
+
export const carryAgentMismatch = (s) => {
|
|
183
|
+
const m = s.firstLine.match(CARRY_SURFACE_H1_RE);
|
|
184
|
+
if (!m) return null;
|
|
185
|
+
const titleSlug = slugify(m[1]);
|
|
186
|
+
return titleSlug === s.agentPrefix ? null : { titleSlug };
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
// Each Carry entry is an H3 block; every block must name a clearance trigger
|
|
190
|
+
// (the `**Carry-clearance:**` marker). Walk the file's H3 boundaries and emit
|
|
191
|
+
// one finding per block missing the marker — the finding[]-returning shape
|
|
192
|
+
// `nothingAfterH2` uses.
|
|
193
|
+
export const carryEntryHasClearance = (s) => {
|
|
194
|
+
const offenders = [];
|
|
195
|
+
let blockStart = -1;
|
|
196
|
+
let hasMarker = false;
|
|
197
|
+
const close = () => {
|
|
198
|
+
if (blockStart !== -1 && !hasMarker) {
|
|
199
|
+
offenders.push({ lineNo: blockStart + 1 });
|
|
200
|
+
}
|
|
201
|
+
};
|
|
202
|
+
for (let i = 0; i < s.fileLines.length; i++) {
|
|
203
|
+
const line = s.fileLines[i];
|
|
204
|
+
if (/^### /.test(line)) {
|
|
205
|
+
close();
|
|
206
|
+
blockStart = i;
|
|
207
|
+
hasMarker = false;
|
|
208
|
+
} else if (blockStart !== -1 && CARRY_CLEARANCE_MARKER_RE.test(line)) {
|
|
209
|
+
hasMarker = true;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
close();
|
|
213
|
+
return offenders.length === 0 ? null : offenders;
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
export const AGENT_H3_REQUIREMENTS = STORYBOARD_DOMAIN_AGENTS.map((agent) => ({
|
|
217
|
+
label: agent,
|
|
218
|
+
pattern: new RegExp(`^### ${agent}(\\s|$|—|-)`),
|
|
219
|
+
}));
|
|
220
|
+
|
|
221
|
+
// -- Metrics CSV duplicate rows --
|
|
222
|
+
|
|
223
|
+
// Report every data line byte-identical to an earlier data line in the same
|
|
224
|
+
// CSV. Line 1 is the header (positionally) and blank lines are skipped; the
|
|
225
|
+
// header is never a duplicate subject. Keying on exact line equality gives the
|
|
226
|
+
// spec's exit path for free: any column edit (run id or note) on one row makes
|
|
227
|
+
// the pair non-identical and stops the finding firing.
|
|
228
|
+
export const duplicateCsvRows = (s) => {
|
|
229
|
+
const seen = new Set();
|
|
230
|
+
const findings = [];
|
|
231
|
+
s.rows.forEach((text, i) => {
|
|
232
|
+
const lineNo = i + 1;
|
|
233
|
+
if (lineNo === 1 || text.trim() === "") return;
|
|
234
|
+
if (seen.has(text)) findings.push({ lineNo });
|
|
235
|
+
else seen.add(text);
|
|
236
|
+
});
|
|
237
|
+
return findings.length === 0 ? null : findings;
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
export const memoryExists = (s) => s.exists;
|
|
241
|
+
export const memoryHasPriorityHeader = (s) =>
|
|
242
|
+
s.exists && PRIORITY_HEADER_RE.test(s.text);
|
|
243
|
+
export const memoryHasClaimsHeading = (s) =>
|
|
244
|
+
s.exists && ACTIVE_CLAIMS_HEADING_RE.test(s.text);
|
|
245
|
+
export const memoryHasClaimsHeader = (s) =>
|
|
246
|
+
ACTIVE_CLAIMS_HEADER_RE.test(s.text);
|
|
247
|
+
export const storyboardExists = (s) => s.exists;
|
package/src/audit/rules.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import {
|
|
2
2
|
ACTIVE_CLAIMS_HEADER_RE,
|
|
3
|
-
ACTIVE_CLAIMS_HEADING,
|
|
4
3
|
ACTIVE_CLAIMS_SEPARATOR_RE,
|
|
5
4
|
ACTIVE_CLAIMS_TABLE_HEADER,
|
|
5
|
+
AGENT_EXPERIMENTS_CLOSE_RE,
|
|
6
|
+
AGENT_EXPERIMENTS_OPEN_RE,
|
|
6
7
|
DECISION_HEADING,
|
|
7
8
|
ISSUE_CLOSE_RE,
|
|
8
9
|
ISSUE_OPEN_RE,
|
|
@@ -13,208 +14,45 @@ import {
|
|
|
13
14
|
SUMMARY_LINE_BUDGET,
|
|
14
15
|
SUMMARY_WORD_BUDGET,
|
|
15
16
|
WEEKLY_LOG_LINE_BUDGET,
|
|
16
|
-
WEEKLY_LOG_SEAM_RE,
|
|
17
17
|
WEEKLY_LOG_WORD_BUDGET,
|
|
18
18
|
XMR_CLOSE_RE,
|
|
19
19
|
XMR_OPEN_RE,
|
|
20
20
|
} from "../constants.js";
|
|
21
|
-
import {
|
|
22
|
-
PRIORITY_HEADER_RE,
|
|
23
|
-
SUMMARY_H1_RE,
|
|
24
|
-
WEEKLY_LOG_H1_RE,
|
|
25
|
-
} from "./scopes.js";
|
|
26
21
|
import { CONFLICT_MARKER_RULE } from "./conflict-markers-rule.js";
|
|
22
|
+
import { PRIORITY_HEADER_RE, WEEKLY_LOG_H1_RE } from "./scopes.js";
|
|
23
|
+
import {
|
|
24
|
+
AGENT_H3_REQUIREMENTS,
|
|
25
|
+
allRequiredLines,
|
|
26
|
+
carryAgentMismatch,
|
|
27
|
+
carryEntryHasClearance,
|
|
28
|
+
columnCount,
|
|
29
|
+
containsLine,
|
|
30
|
+
decisionWithin5,
|
|
31
|
+
duplicateCsvRows,
|
|
32
|
+
exists,
|
|
33
|
+
expired,
|
|
34
|
+
fieldMatches,
|
|
35
|
+
firstH2Is,
|
|
36
|
+
firstLineMatches,
|
|
37
|
+
headingGrammarDrift,
|
|
38
|
+
ISO_DATE_RE,
|
|
39
|
+
lineBudget,
|
|
40
|
+
markersBalanced,
|
|
41
|
+
matches,
|
|
42
|
+
memoryExists,
|
|
43
|
+
memoryHasClaimsHeading,
|
|
44
|
+
memoryHasClaimsHeader,
|
|
45
|
+
memoryHasPriorityHeader,
|
|
46
|
+
nothingAfterH2,
|
|
47
|
+
PRIORITY_INDEX_HEADING_RE,
|
|
48
|
+
PRIORITY_SEPARATOR_RE,
|
|
49
|
+
storyboardExists,
|
|
50
|
+
summaryAgentMismatch,
|
|
51
|
+
weeklyAgentMismatch,
|
|
52
|
+
wordBudget,
|
|
53
|
+
} from "./rule-builders.js";
|
|
27
54
|
import { STATUS_ROW_RULES } from "./status-row.js";
|
|
28
55
|
|
|
29
|
-
const PRIORITY_INDEX_HEADING_RE = new RegExp(
|
|
30
|
-
`^${PRIORITY_INDEX_HEADING}$`,
|
|
31
|
-
"m",
|
|
32
|
-
);
|
|
33
|
-
const ACTIVE_CLAIMS_HEADING_RE = new RegExp(`^${ACTIVE_CLAIMS_HEADING}$`, "m");
|
|
34
|
-
const PRIORITY_SEPARATOR_RE =
|
|
35
|
-
/^\|\s*---\s*\|\s*---\s*\|\s*---\s*\|\s*---\s*\|\s*---\s*\|/m;
|
|
36
|
-
const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
|
37
|
-
|
|
38
|
-
// improvement-coach is the storyboard facilitator and carries no domain
|
|
39
|
-
// metrics; only the five domain agents need their own H3.
|
|
40
|
-
const STORYBOARD_DOMAIN_AGENTS = [
|
|
41
|
-
"product-manager",
|
|
42
|
-
"release-engineer",
|
|
43
|
-
"security-engineer",
|
|
44
|
-
"staff-engineer",
|
|
45
|
-
"technical-writer",
|
|
46
|
-
];
|
|
47
|
-
|
|
48
|
-
// -- Check builders: subject (+ ctx) → null | finding | finding[] --
|
|
49
|
-
|
|
50
|
-
const matches = (pattern) => (s) => (pattern.test(s.text) ? null : {});
|
|
51
|
-
const firstLineMatches = (pattern) => (s) =>
|
|
52
|
-
pattern.test(s.firstLine) ? null : {};
|
|
53
|
-
const containsLine = (needle) => (s) =>
|
|
54
|
-
s.fileLines.some((l) => l.trim() === needle) ? null : {};
|
|
55
|
-
|
|
56
|
-
const lineBudget = (limit) => (s) =>
|
|
57
|
-
s.lines > limit ? { value: s.lines } : null;
|
|
58
|
-
const wordBudget = (limit) => (s) =>
|
|
59
|
-
s.words > limit ? { value: s.words } : null;
|
|
60
|
-
|
|
61
|
-
const firstH2Is = (expected) => (s) =>
|
|
62
|
-
s.h2s.length === 0 || s.h2s[0] === expected ? null : { observed: s.h2s[0] };
|
|
63
|
-
|
|
64
|
-
const nothingAfterH2 = (marker) => (s) => {
|
|
65
|
-
const idx = s.h2s.indexOf(marker);
|
|
66
|
-
if (idx === -1) return null;
|
|
67
|
-
const after = s.h2s.slice(idx + 1);
|
|
68
|
-
return after.length === 0 ? null : after.map((h) => ({ observed: h }));
|
|
69
|
-
};
|
|
70
|
-
|
|
71
|
-
const fieldMatches = (name, pattern) => (s) =>
|
|
72
|
-
pattern.test(s[name]) ? null : { value: s[name] };
|
|
73
|
-
|
|
74
|
-
const columnCount = (expected) => (s) =>
|
|
75
|
-
s.cells.length === expected ? null : { actual: s.cells.length, expected };
|
|
76
|
-
|
|
77
|
-
const exists = (s) => (s.exists ? null : {});
|
|
78
|
-
const expired = (s, ctx) => (s.expires_at < ctx.today ? {} : null);
|
|
79
|
-
|
|
80
|
-
// The heading must equal `requiredLine` exactly — a suffixed variant like
|
|
81
|
-
// "### Decision — <summary>" does not satisfy it, but is reported as a
|
|
82
|
-
// near miss so the writer fixes the heading instead of hunting for a
|
|
83
|
-
// "missing" line that is right there.
|
|
84
|
-
function entryHasDecision(lines, startIdx, requiredLine, stopRe) {
|
|
85
|
-
let seen = 0;
|
|
86
|
-
let nearMiss = null;
|
|
87
|
-
for (let j = startIdx + 1; j < lines.length && seen < 5; j++) {
|
|
88
|
-
const ln = lines[j].trim();
|
|
89
|
-
if (ln === "") continue;
|
|
90
|
-
seen++;
|
|
91
|
-
if (ln === requiredLine) return { found: true };
|
|
92
|
-
if (nearMiss === null && ln.startsWith(requiredLine)) nearMiss = ln;
|
|
93
|
-
if (stopRe.test(lines[j])) break;
|
|
94
|
-
}
|
|
95
|
-
return { found: false, nearMiss };
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
const decisionWithin5 =
|
|
99
|
-
({ entryRe, requiredLine, stopRe }) =>
|
|
100
|
-
(s) => {
|
|
101
|
-
const offenders = [];
|
|
102
|
-
for (let i = 0; i < s.fileLines.length; i++) {
|
|
103
|
-
if (!entryRe.test(s.fileLines[i])) continue;
|
|
104
|
-
const res = entryHasDecision(s.fileLines, i, requiredLine, stopRe);
|
|
105
|
-
if (!res.found) offenders.push({ lineNo: i + 1, nearMiss: res.nearMiss });
|
|
106
|
-
}
|
|
107
|
-
return offenders.length === 0 ? null : offenders;
|
|
108
|
-
};
|
|
109
|
-
|
|
110
|
-
// Flag entry-shaped `## ` headings that the rotation seam-finder would skip —
|
|
111
|
-
// the grammar-drift that degrades a whole file to one unsplittable prologue and
|
|
112
|
-
// is otherwise silent (the decision-block rule matches only dated headings).
|
|
113
|
-
// Uses the same WEEKLY_LOG_SEAM_RE the seam-finder uses, so the flagged set is
|
|
114
|
-
// exactly the complement of the rotatable set.
|
|
115
|
-
const headingGrammarDrift = (s) => {
|
|
116
|
-
const offenders = [];
|
|
117
|
-
for (let i = 0; i < s.fileLines.length; i++) {
|
|
118
|
-
const line = s.fileLines[i];
|
|
119
|
-
if (/^## /.test(line) && !WEEKLY_LOG_SEAM_RE.test(line)) {
|
|
120
|
-
offenders.push({ lineNo: i + 1, observed: line.trim() });
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
return offenders.length === 0 ? null : offenders;
|
|
124
|
-
};
|
|
125
|
-
|
|
126
|
-
function scanMarkers(fileLines, openRe, closeRe, label) {
|
|
127
|
-
const openings = [];
|
|
128
|
-
const findings = [];
|
|
129
|
-
for (let i = 0; i < fileLines.length; i++) {
|
|
130
|
-
const openMatch = fileLines[i].match(openRe);
|
|
131
|
-
if (openMatch) {
|
|
132
|
-
openings.push({ label: openMatch[1] || label, lineNo: i + 1 });
|
|
133
|
-
} else if (closeRe.test(fileLines[i])) {
|
|
134
|
-
if (openings.length > 0) openings.pop();
|
|
135
|
-
else findings.push({ lineNo: i + 1, reason: "unpaired-close" });
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
for (const open of openings) {
|
|
139
|
-
findings.push({
|
|
140
|
-
lineNo: open.lineNo,
|
|
141
|
-
reason: "dangling-open",
|
|
142
|
-
label: open.label,
|
|
143
|
-
});
|
|
144
|
-
}
|
|
145
|
-
return findings;
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
const markersBalanced =
|
|
149
|
-
({ openRe, closeRe, label }) =>
|
|
150
|
-
(s) => {
|
|
151
|
-
const findings = scanMarkers(s.fileLines, openRe, closeRe, label);
|
|
152
|
-
return findings.length === 0 ? null : findings;
|
|
153
|
-
};
|
|
154
|
-
|
|
155
|
-
const allRequiredLines = (required) => (s) => {
|
|
156
|
-
const findings = [];
|
|
157
|
-
for (const r of required) {
|
|
158
|
-
if (!s.fileLines.some((l) => r.pattern.test(l))) {
|
|
159
|
-
findings.push({ label: r.label });
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
return findings.length === 0 ? null : findings;
|
|
163
|
-
};
|
|
164
|
-
|
|
165
|
-
// -- H1 → filename agent prefix --
|
|
166
|
-
|
|
167
|
-
const slugify = (title) =>
|
|
168
|
-
title
|
|
169
|
-
.trim()
|
|
170
|
-
.toLowerCase()
|
|
171
|
-
.replace(/[^a-z0-9]+/g, "-")
|
|
172
|
-
.replace(/(^-|-$)/g, "");
|
|
173
|
-
|
|
174
|
-
const summaryAgentMismatch = (s) => {
|
|
175
|
-
const titleSlug = slugify(s.firstLine.match(SUMMARY_H1_RE)[1]);
|
|
176
|
-
return titleSlug === s.agentPrefix ? null : { titleSlug };
|
|
177
|
-
};
|
|
178
|
-
|
|
179
|
-
const weeklyAgentMismatch = (s) => {
|
|
180
|
-
const m = s.firstLine.match(WEEKLY_LOG_H1_RE);
|
|
181
|
-
if (!m) return null;
|
|
182
|
-
const titleSlug = slugify(m[1]);
|
|
183
|
-
return titleSlug === s.agentPrefix ? null : { titleSlug };
|
|
184
|
-
};
|
|
185
|
-
|
|
186
|
-
const AGENT_H3_REQUIREMENTS = STORYBOARD_DOMAIN_AGENTS.map((agent) => ({
|
|
187
|
-
label: agent,
|
|
188
|
-
pattern: new RegExp(`^### ${agent}(\\s|$|—|-)`),
|
|
189
|
-
}));
|
|
190
|
-
|
|
191
|
-
// -- Metrics CSV duplicate rows --
|
|
192
|
-
|
|
193
|
-
// Report every data line byte-identical to an earlier data line in the same
|
|
194
|
-
// CSV. Line 1 is the header (positionally) and blank lines are skipped; the
|
|
195
|
-
// header is never a duplicate subject. Keying on exact line equality gives the
|
|
196
|
-
// spec's exit path for free: any column edit (run id or note) on one row makes
|
|
197
|
-
// the pair non-identical and stops the finding firing.
|
|
198
|
-
const duplicateCsvRows = (s) => {
|
|
199
|
-
const seen = new Set();
|
|
200
|
-
const findings = [];
|
|
201
|
-
s.rows.forEach((text, i) => {
|
|
202
|
-
const lineNo = i + 1;
|
|
203
|
-
if (lineNo === 1 || text.trim() === "") return;
|
|
204
|
-
if (seen.has(text)) findings.push({ lineNo });
|
|
205
|
-
else seen.add(text);
|
|
206
|
-
});
|
|
207
|
-
return findings.length === 0 ? null : findings;
|
|
208
|
-
};
|
|
209
|
-
|
|
210
|
-
const memoryExists = (s) => s.exists;
|
|
211
|
-
const memoryHasPriorityHeader = (s) =>
|
|
212
|
-
s.exists && PRIORITY_HEADER_RE.test(s.text);
|
|
213
|
-
const memoryHasClaimsHeading = (s) =>
|
|
214
|
-
s.exists && ACTIVE_CLAIMS_HEADING_RE.test(s.text);
|
|
215
|
-
const memoryHasClaimsHeader = (s) => ACTIVE_CLAIMS_HEADER_RE.test(s.text);
|
|
216
|
-
const storyboardExists = (s) => s.exists;
|
|
217
|
-
|
|
218
56
|
export const RULES = [
|
|
219
57
|
// -- Summary files --
|
|
220
58
|
|
|
@@ -388,6 +226,30 @@ export const RULES = [
|
|
|
388
226
|
hint: "rename either the H1 or the file so they agree",
|
|
389
227
|
},
|
|
390
228
|
|
|
229
|
+
// -- Carry surfaces --
|
|
230
|
+
// No `h1-shape` rule: unlike the weekly logs (classified on filename alone),
|
|
231
|
+
// the carry classifier (scopes.js) requires the Carry H1 before assigning the
|
|
232
|
+
// scope, so a malformed-H1 file is left unclassified rather than reaching an
|
|
233
|
+
// h1-shape rule. The two rules below are the reachable, failable set (SC #2).
|
|
234
|
+
|
|
235
|
+
{
|
|
236
|
+
id: "carry-surface.h1-agent-matches-filename",
|
|
237
|
+
scope: "carry-surface",
|
|
238
|
+
severity: "fail",
|
|
239
|
+
check: carryAgentMismatch,
|
|
240
|
+
message: (s, r) =>
|
|
241
|
+
`H1 title slug '${r.titleSlug}' does not match filename prefix '${s.agentPrefix}'`,
|
|
242
|
+
hint: "rename either the H1 ('# <agent> — Carries') or the file so they agree",
|
|
243
|
+
},
|
|
244
|
+
{
|
|
245
|
+
id: "carry-surface.entry-has-clearance",
|
|
246
|
+
scope: "carry-surface",
|
|
247
|
+
severity: "fail",
|
|
248
|
+
check: carryEntryHasClearance,
|
|
249
|
+
message: () => "Carry entry lacks a '**Carry-clearance:**' trigger line",
|
|
250
|
+
hint: "every '### ' Carry entry must name its clearance trigger with a '**Carry-clearance:**' line so the surface stays enumerable at boot",
|
|
251
|
+
},
|
|
252
|
+
|
|
391
253
|
// -- MEMORY.md --
|
|
392
254
|
|
|
393
255
|
{
|
|
@@ -544,6 +406,20 @@ export const RULES = [
|
|
|
544
406
|
`${r.reason} issue-list marker${r.label ? ` (${r.label})` : ""}`,
|
|
545
407
|
hint: "every '<!-- obstacles:* -->' or '<!-- experiments:* -->' needs a matching close marker",
|
|
546
408
|
},
|
|
409
|
+
{
|
|
410
|
+
id: "storyboard.markers-balanced.agent-experiments",
|
|
411
|
+
scope: "storyboard",
|
|
412
|
+
severity: "fail",
|
|
413
|
+
when: storyboardExists,
|
|
414
|
+
check: markersBalanced({
|
|
415
|
+
openRe: AGENT_EXPERIMENTS_OPEN_RE,
|
|
416
|
+
closeRe: AGENT_EXPERIMENTS_CLOSE_RE,
|
|
417
|
+
label: "agent-experiments",
|
|
418
|
+
}),
|
|
419
|
+
message: (_s, r) =>
|
|
420
|
+
`${r.reason} agent-experiments marker${r.label ? ` (${r.label})` : ""}`,
|
|
421
|
+
hint: "every '<!-- agent-experiments -->' needs a matching '<!-- /agent-experiments -->'",
|
|
422
|
+
},
|
|
547
423
|
|
|
548
424
|
// -- Metrics CSVs (union merge keeps both sides on concurrent appends;
|
|
549
425
|
// exact-duplicate rows are surfaced here, never silently removed) --
|
package/src/audit/scopes.js
CHANGED
|
@@ -4,6 +4,8 @@ import { parseClaims } from "../active-claims.js";
|
|
|
4
4
|
import { countLines, countWords } from "../budget.js";
|
|
5
5
|
import { parseStatusRowId } from "../status.js";
|
|
6
6
|
import {
|
|
7
|
+
CARRY_SURFACE_H1_RE,
|
|
8
|
+
CARRY_SURFACE_NAME_RE,
|
|
7
9
|
PRIORITY_INDEX_HEADING,
|
|
8
10
|
WEEKLY_LOG_NAME_RE,
|
|
9
11
|
WEEKLY_LOG_PART_NAME_RE,
|
|
@@ -75,6 +77,11 @@ function loadFile(filePath, fs) {
|
|
|
75
77
|
const base = path.basename(filePath);
|
|
76
78
|
const weekMatch =
|
|
77
79
|
base.match(WEEKLY_LOG_NAME_RE) || base.match(WEEKLY_LOG_PART_NAME_RE);
|
|
80
|
+
const carryMatch = base.match(CARRY_SURFACE_NAME_RE);
|
|
81
|
+
let agentPrefix;
|
|
82
|
+
if (weekMatch) agentPrefix = weekMatch[1];
|
|
83
|
+
else if (carryMatch) agentPrefix = carryMatch[1];
|
|
84
|
+
else agentPrefix = base.replace(/\.md$/, "");
|
|
78
85
|
return {
|
|
79
86
|
path: filePath,
|
|
80
87
|
text,
|
|
@@ -83,7 +90,7 @@ function loadFile(filePath, fs) {
|
|
|
83
90
|
h2s,
|
|
84
91
|
lines: countLines(text),
|
|
85
92
|
words: countWords(text),
|
|
86
|
-
agentPrefix
|
|
93
|
+
agentPrefix,
|
|
87
94
|
};
|
|
88
95
|
}
|
|
89
96
|
|
|
@@ -101,6 +108,17 @@ function classifyFile(filePath, fs) {
|
|
|
101
108
|
return { kind: "weekly-log-part", subject: loadFile(filePath, fs) };
|
|
102
109
|
}
|
|
103
110
|
const subject = loadFile(filePath, fs);
|
|
111
|
+
// Carry surface: a `<agent>-carries.md` whose H1 matches the Carry H1 RE.
|
|
112
|
+
// Both axes must match (filename prefix and H1), mirroring the summary
|
|
113
|
+
// classifier. The two H1 REs end in distinct literals (`— Carries` vs
|
|
114
|
+
// `— Summary`) so the branches cannot cross-capture regardless of order;
|
|
115
|
+
// a name-match + H1-miss is left unclassified, like a malformed summary.
|
|
116
|
+
if (CARRY_SURFACE_NAME_RE.test(base)) {
|
|
117
|
+
if (CARRY_SURFACE_H1_RE.test(subject.firstLine)) {
|
|
118
|
+
return { kind: "carry-surface", subject };
|
|
119
|
+
}
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
104
122
|
// Files that do not match a summary or weekly-log shape are left
|
|
105
123
|
// unclassified: stray files are not audited.
|
|
106
124
|
if (!SUMMARY_H1_RE.test(subject.firstLine)) return null;
|
|
@@ -219,6 +237,7 @@ const SCOPE_RESOLVERS = {
|
|
|
219
237
|
"weekly-log-main": (ctx) => ctx.subjects["weekly-log-main"],
|
|
220
238
|
"weekly-log-part": (ctx) => ctx.subjects["weekly-log-part"],
|
|
221
239
|
"metrics-csv": (ctx) => ctx.subjects["metrics-csv"],
|
|
240
|
+
"carry-surface": (ctx) => ctx.subjects["carry-surface"],
|
|
222
241
|
memory: (ctx) => [ctx.memory],
|
|
223
242
|
"claims-row": (ctx) =>
|
|
224
243
|
parseClaims(ctx.memory.text).map((c) => ({ ...c, path: ctx.memory.path })),
|
|
@@ -322,6 +341,7 @@ export function buildContext({ wikiRoot, today, fs, subprocess }) {
|
|
|
322
341
|
"weekly-log-main": [],
|
|
323
342
|
"weekly-log-part": [],
|
|
324
343
|
"metrics-csv": [],
|
|
344
|
+
"carry-surface": [],
|
|
325
345
|
};
|
|
326
346
|
for (const file of listMdFiles(wikiRoot, fs)) {
|
|
327
347
|
const classified = classifyFile(file, fs);
|