@forwardimpact/libwiki 0.2.26 → 0.2.28
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/rules.js +16 -0
- package/src/audit/scopes.js +13 -2
- package/src/audit/status-row.js +91 -6
- 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 +18 -0
- package/src/issue-list-renderer.js +71 -0
- package/src/marker-scanner.js +18 -1
- package/src/sanitize.js +53 -0
- package/src/status.js +39 -12
- package/src/wiki-sync.js +573 -56
package/package.json
CHANGED
package/src/audit/rules.js
CHANGED
|
@@ -3,6 +3,8 @@ import {
|
|
|
3
3
|
ACTIVE_CLAIMS_HEADING,
|
|
4
4
|
ACTIVE_CLAIMS_SEPARATOR_RE,
|
|
5
5
|
ACTIVE_CLAIMS_TABLE_HEADER,
|
|
6
|
+
AGENT_EXPERIMENTS_CLOSE_RE,
|
|
7
|
+
AGENT_EXPERIMENTS_OPEN_RE,
|
|
6
8
|
DECISION_HEADING,
|
|
7
9
|
ISSUE_CLOSE_RE,
|
|
8
10
|
ISSUE_OPEN_RE,
|
|
@@ -544,6 +546,20 @@ export const RULES = [
|
|
|
544
546
|
`${r.reason} issue-list marker${r.label ? ` (${r.label})` : ""}`,
|
|
545
547
|
hint: "every '<!-- obstacles:* -->' or '<!-- experiments:* -->' needs a matching close marker",
|
|
546
548
|
},
|
|
549
|
+
{
|
|
550
|
+
id: "storyboard.markers-balanced.agent-experiments",
|
|
551
|
+
scope: "storyboard",
|
|
552
|
+
severity: "fail",
|
|
553
|
+
when: storyboardExists,
|
|
554
|
+
check: markersBalanced({
|
|
555
|
+
openRe: AGENT_EXPERIMENTS_OPEN_RE,
|
|
556
|
+
closeRe: AGENT_EXPERIMENTS_CLOSE_RE,
|
|
557
|
+
label: "agent-experiments",
|
|
558
|
+
}),
|
|
559
|
+
message: (_s, r) =>
|
|
560
|
+
`${r.reason} agent-experiments marker${r.label ? ` (${r.label})` : ""}`,
|
|
561
|
+
hint: "every '<!-- agent-experiments -->' needs a matching '<!-- /agent-experiments -->'",
|
|
562
|
+
},
|
|
547
563
|
|
|
548
564
|
// -- Metrics CSVs (union merge keeps both sides on concurrent appends;
|
|
549
565
|
// exact-duplicate rows are surfaced here, never silently removed) --
|
package/src/audit/scopes.js
CHANGED
|
@@ -2,6 +2,7 @@ import path from "node:path";
|
|
|
2
2
|
import { yearMonth } from "@forwardimpact/libutil";
|
|
3
3
|
import { parseClaims } from "../active-claims.js";
|
|
4
4
|
import { countLines, countWords } from "../budget.js";
|
|
5
|
+
import { parseStatusRowId } from "../status.js";
|
|
5
6
|
import {
|
|
6
7
|
PRIORITY_INDEX_HEADING,
|
|
7
8
|
WEEKLY_LOG_NAME_RE,
|
|
@@ -120,9 +121,12 @@ function readOptional(filePath, fs) {
|
|
|
120
121
|
|
|
121
122
|
/**
|
|
122
123
|
* Parse the rows inside STATUS.md's fenced block into audit subjects. Lines
|
|
123
|
-
* outside the ``` fence (header prose) and blank lines are skipped.
|
|
124
|
+
* outside the ``` fence (header prose) and blank lines are skipped. Each row
|
|
125
|
+
* carries a `kind` from {@link parseStatusRowId} (`"spec"`, `"experiment"`, or
|
|
126
|
+
* `null` for an unrecognized id); spec-shaped rules read the positional
|
|
127
|
+
* `id`/`phase`/`status` fields, experiment rules read `cells`.
|
|
124
128
|
* @param {string} statusText - The full STATUS.md contents.
|
|
125
|
-
* @returns {Array<{lineNo: number, text: string, cells: string[], id: string, phase: string, status: string}>}
|
|
129
|
+
* @returns {Array<{lineNo: number, text: string, cells: string[], id: string, phase: string, status: string, kind: string|null}>}
|
|
126
130
|
*/
|
|
127
131
|
function parseStatusRows(statusText) {
|
|
128
132
|
const lines = statusText.split("\n");
|
|
@@ -136,6 +140,12 @@ function parseStatusRows(statusText) {
|
|
|
136
140
|
}
|
|
137
141
|
if (!inFence || line.trim() === "") continue;
|
|
138
142
|
const cells = line.split("\t");
|
|
143
|
+
// Classify by id prefix so a malformed `exp:` row (e.g. wrong cell count)
|
|
144
|
+
// is still routed to the experiment rules, which flag it — rather than
|
|
145
|
+
// slipping through the spec-shaped rules. parseStatusRowId returns the
|
|
146
|
+
// structured fields only for a well-formed row; the rules read `cells`.
|
|
147
|
+
const isExp = typeof cells[0] === "string" && cells[0].startsWith("exp:");
|
|
148
|
+
const parsed = parseStatusRowId(cells[0], cells);
|
|
139
149
|
rows.push({
|
|
140
150
|
lineNo: i + 1,
|
|
141
151
|
text: line,
|
|
@@ -143,6 +153,7 @@ function parseStatusRows(statusText) {
|
|
|
143
153
|
id: cells[0],
|
|
144
154
|
phase: cells[1],
|
|
145
155
|
status: cells[2],
|
|
156
|
+
kind: isExp ? "experiment" : parsed ? parsed.kind : null,
|
|
146
157
|
});
|
|
147
158
|
}
|
|
148
159
|
return rows;
|
package/src/audit/status-row.js
CHANGED
|
@@ -1,24 +1,37 @@
|
|
|
1
1
|
import { STATUS_ID_REGEX } from "../status.js";
|
|
2
2
|
|
|
3
|
-
// Validate every row inside wiki/STATUS.md's code fence
|
|
4
|
-
// `
|
|
5
|
-
//
|
|
3
|
+
// Validate every row inside wiki/STATUS.md's code fence. Rows are resolved by
|
|
4
|
+
// the `status-row` scope in scopes.js; each subject carries
|
|
5
|
+
// `{ cells, id, phase, status, kind, text }`. Two row kinds share the fence:
|
|
6
|
+
//
|
|
7
|
+
// spec `{id}<TAB>{phase}<TAB>{status}` — three cells
|
|
8
|
+
// experiment `exp:{issue}<TAB>{state}<TAB>{pin}<TAB>{plan-ref}` — four cells
|
|
9
|
+
//
|
|
10
|
+
// Spec-shaped rules run for every non-experiment row (`kind !== "experiment"`,
|
|
11
|
+
// which includes an unrecognized id so a malformed id still flags). Experiment
|
|
12
|
+
// rules run only for `kind === "experiment"`.
|
|
6
13
|
|
|
7
14
|
const PHASES = new Set(["spec", "design", "plan"]);
|
|
8
15
|
const STATUSES = new Set(["draft", "approved", "implemented", "cancelled"]);
|
|
16
|
+
const EXP_STATES = new Set(["registered", "approved", "cancelled"]);
|
|
17
|
+
const PIN_RE = /^[0-9a-f]{40}$/;
|
|
9
18
|
|
|
10
|
-
const
|
|
19
|
+
const isSpecShaped = (s) => s.kind !== "experiment";
|
|
20
|
+
const isExperiment = (s) => s.kind === "experiment";
|
|
21
|
+
const hasThreeCells = (s) => isSpecShaped(s) && s.cells.length === 3;
|
|
22
|
+
const hasFourCells = (s) => isExperiment(s) && s.cells.length === 4;
|
|
11
23
|
|
|
12
24
|
export const STATUS_ROW_RULES = [
|
|
13
25
|
{
|
|
14
26
|
id: "status-row.shape",
|
|
15
27
|
scope: "status-row",
|
|
16
28
|
severity: "fail",
|
|
29
|
+
when: isSpecShaped,
|
|
17
30
|
check: (s) =>
|
|
18
|
-
|
|
31
|
+
s.cells.length === 3 ? null : { actual: s.cells.length, text: s.text },
|
|
19
32
|
message: (_s, r) =>
|
|
20
33
|
`${r.actual} tab-separated field(s), expected 3: "${r.text}"`,
|
|
21
|
-
hint: "each STATUS row is `{id}<TAB>{phase}<TAB>{status}`",
|
|
34
|
+
hint: "each spec STATUS row is `{id}<TAB>{phase}<TAB>{status}`",
|
|
22
35
|
},
|
|
23
36
|
{
|
|
24
37
|
id: "status-row.id-format",
|
|
@@ -48,4 +61,76 @@ export const STATUS_ROW_RULES = [
|
|
|
48
61
|
`Bad status '${r.status}' (expected draft|approved|implemented|cancelled)`,
|
|
49
62
|
hint: "status is one of draft, approved, implemented, cancelled",
|
|
50
63
|
},
|
|
64
|
+
{
|
|
65
|
+
id: "status-row.exp-shape",
|
|
66
|
+
scope: "status-row",
|
|
67
|
+
severity: "fail",
|
|
68
|
+
when: isExperiment,
|
|
69
|
+
check: (s) =>
|
|
70
|
+
s.cells.length === 4 ? null : { actual: s.cells.length, text: s.text },
|
|
71
|
+
message: (_s, r) =>
|
|
72
|
+
`${r.actual} tab-separated field(s), expected 4: "${r.text}"`,
|
|
73
|
+
hint: "each experiment row is `exp:{issue}<TAB>{state}<TAB>{pin}<TAB>{plan-ref}`",
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
// An experiment-kind row is classified by its `exp:` id prefix
|
|
77
|
+
// (scopes.js), so the spec `id-format` rule is skipped for it; this rule
|
|
78
|
+
// enforces the `exp:\d+` id so a non-numeric issue (e.g. `exp:abc`) flags
|
|
79
|
+
// rather than auditing clean — keeping the audit aligned with
|
|
80
|
+
// STATUS_ID_REGEX / parseStatusRowId.
|
|
81
|
+
id: "status-row.exp-id-format",
|
|
82
|
+
scope: "status-row",
|
|
83
|
+
severity: "fail",
|
|
84
|
+
when: isExperiment,
|
|
85
|
+
check: (s) => (/^exp:\d+$/.test(s.id) ? null : { id: s.id }),
|
|
86
|
+
message: (_s, r) => `Bad experiment id '${r.id}' (expected exp:NNN)`,
|
|
87
|
+
hint: "an experiment id is `exp:` followed by the issue number",
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
id: "status-row.exp-state",
|
|
91
|
+
scope: "status-row",
|
|
92
|
+
severity: "fail",
|
|
93
|
+
when: hasFourCells,
|
|
94
|
+
check: (s) => (EXP_STATES.has(s.cells[1]) ? null : { state: s.cells[1] }),
|
|
95
|
+
message: (_s, r) =>
|
|
96
|
+
`Bad experiment state '${r.state}' (expected registered|approved|cancelled)`,
|
|
97
|
+
hint: "experiment state is one of registered, approved, cancelled",
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
id: "status-row.exp-pin",
|
|
101
|
+
scope: "status-row",
|
|
102
|
+
severity: "fail",
|
|
103
|
+
when: hasFourCells,
|
|
104
|
+
// The pin is decidable per state, with no "ever approved" inference: a
|
|
105
|
+
// `registered` row has no pin (`-`); an `approved` row pins the 40-hex
|
|
106
|
+
// head; a `cancelled` row may carry the retained pin or `-` (it may or may
|
|
107
|
+
// not have been approved before cancellation), so both are accepted.
|
|
108
|
+
check: (s) => {
|
|
109
|
+
const [, state, pin] = s.cells;
|
|
110
|
+
if (state === "registered") {
|
|
111
|
+
return pin === "-" ? null : { state, pin, want: "-" };
|
|
112
|
+
}
|
|
113
|
+
if (state === "approved") {
|
|
114
|
+
return PIN_RE.test(pin) ? null : { state, pin, want: "a 40-hex SHA" };
|
|
115
|
+
}
|
|
116
|
+
if (state === "cancelled") {
|
|
117
|
+
return pin === "-" || PIN_RE.test(pin)
|
|
118
|
+
? null
|
|
119
|
+
: { state, pin, want: "`-` or a 40-hex SHA" };
|
|
120
|
+
}
|
|
121
|
+
return null; // bad state already flagged by exp-state
|
|
122
|
+
},
|
|
123
|
+
message: (_s, r) =>
|
|
124
|
+
`Bad pin '${r.pin}' for state '${r.state}' (expected ${r.want})`,
|
|
125
|
+
hint: "registered pins `-`; approved pins a 40-hex SHA; cancelled pins either",
|
|
126
|
+
},
|
|
127
|
+
{
|
|
128
|
+
id: "status-row.exp-planref",
|
|
129
|
+
scope: "status-row",
|
|
130
|
+
severity: "fail",
|
|
131
|
+
when: hasFourCells,
|
|
132
|
+
check: (s) => (/^#\d+$/.test(s.cells[3]) ? null : { planRef: s.cells[3] }),
|
|
133
|
+
message: (_s, r) => `Bad plan-ref '${r.planRef}' (expected #NNN)`,
|
|
134
|
+
hint: "the plan-ref names the issue carrying the execution plan, e.g. #NNN",
|
|
135
|
+
},
|
|
51
136
|
];
|
package/src/boot.js
CHANGED
|
@@ -3,6 +3,9 @@ import { yearMonth } from "@forwardimpact/libutil";
|
|
|
3
3
|
import { parseClaims, filterExpired } from "./active-claims.js";
|
|
4
4
|
import { countLines, countWords } from "./budget.js";
|
|
5
5
|
import {
|
|
6
|
+
AGENT_EXPERIMENTS_CLOSE_RE,
|
|
7
|
+
AGENT_EXPERIMENTS_OPEN_RE,
|
|
8
|
+
AGENT_EXPERIMENT_ITEM_RE,
|
|
6
9
|
MEMO_INBOX_MARKER,
|
|
7
10
|
PRIORITY_INDEX_HEADING,
|
|
8
11
|
SUMMARY_LINE_BUDGET,
|
|
@@ -12,6 +15,8 @@ import {
|
|
|
12
15
|
} from "./constants.js";
|
|
13
16
|
import { weeklyLogPath } from "./weekly-log.js";
|
|
14
17
|
|
|
18
|
+
const STANDING_CARRIES_HEADING = "## Standing Carries";
|
|
19
|
+
|
|
15
20
|
function readIfExists(fs, filePath) {
|
|
16
21
|
if (!fs.existsSync(filePath)) return null;
|
|
17
22
|
return fs.readFileSync(filePath, "utf-8");
|
|
@@ -96,35 +101,89 @@ function splitPriorities(rows, agent) {
|
|
|
96
101
|
return { owned, cross };
|
|
97
102
|
}
|
|
98
103
|
|
|
104
|
+
// Parse an attributed item line from the materialized block for `agent`.
|
|
105
|
+
// Returns the unified item shape or null (wrong agent / not an item line).
|
|
106
|
+
function parseBlockItem(line, agent) {
|
|
107
|
+
const m = line.match(AGENT_EXPERIMENT_ITEM_RE);
|
|
108
|
+
if (!m || m[2] !== agent) return null;
|
|
109
|
+
return {
|
|
110
|
+
dim: agent,
|
|
111
|
+
threshold: m[3],
|
|
112
|
+
status: "open",
|
|
113
|
+
link: null,
|
|
114
|
+
issue: Number(m[1]),
|
|
115
|
+
author: m[4],
|
|
116
|
+
source: "experiment",
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function bulletItem(threshold, agent) {
|
|
121
|
+
return {
|
|
122
|
+
dim: agent,
|
|
123
|
+
threshold,
|
|
124
|
+
status: "open",
|
|
125
|
+
link: null,
|
|
126
|
+
issue: null,
|
|
127
|
+
author: null,
|
|
128
|
+
source: "bullet",
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Advance the agent-section scan for one storyboard line that is NOT inside the
|
|
133
|
+
// materialized block. Returns the next `inAgent` state and pushes an h3-bullet
|
|
134
|
+
// item for the booting agent when one is found. An h2 ends the agent-section
|
|
135
|
+
// scan (team-wide sections follow the last agent h3 — without this the scan
|
|
136
|
+
// would run past the agent sections and misattribute team-wide bullets).
|
|
137
|
+
function scanAgentLine(line, agent, inAgent, items) {
|
|
138
|
+
if (/^## /.test(line)) return false;
|
|
139
|
+
const h3Match = line.match(/^### (.+)$/);
|
|
140
|
+
if (h3Match) {
|
|
141
|
+
return h3Match[1].toLowerCase().startsWith(agent.toLowerCase());
|
|
142
|
+
}
|
|
143
|
+
const bullet = inAgent && line.match(/^[-*]\s+(.+)$/);
|
|
144
|
+
if (bullet) items.push(bulletItem(bullet[1], agent));
|
|
145
|
+
return inAgent;
|
|
146
|
+
}
|
|
147
|
+
|
|
99
148
|
function parseStoryboardItems(text, agent) {
|
|
100
149
|
if (!text) return [];
|
|
101
|
-
const lines = text.split("\n");
|
|
102
150
|
const items = [];
|
|
103
151
|
let inAgent = false;
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
152
|
+
let inBlock = false;
|
|
153
|
+
for (const line of text.split("\n")) {
|
|
154
|
+
// The materialized block carries `- #N [agent] …` bullets that the agent
|
|
155
|
+
// scan must never capture; track it so the bullet loop skips inside it.
|
|
156
|
+
// (Without it the legacy scan double-counted these as the last agent's bullets.)
|
|
157
|
+
if (AGENT_EXPERIMENTS_OPEN_RE.test(line)) {
|
|
158
|
+
inBlock = true;
|
|
111
159
|
inAgent = false;
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
if (
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
threshold: bullet[1],
|
|
120
|
-
status: "open",
|
|
121
|
-
link: null,
|
|
122
|
-
});
|
|
160
|
+
} else if (AGENT_EXPERIMENTS_CLOSE_RE.test(line)) {
|
|
161
|
+
inBlock = false;
|
|
162
|
+
} else if (inBlock) {
|
|
163
|
+
const item = parseBlockItem(line, agent);
|
|
164
|
+
if (item) items.push(item);
|
|
165
|
+
} else {
|
|
166
|
+
inAgent = scanAgentLine(line, agent, inAgent, items);
|
|
123
167
|
}
|
|
124
168
|
}
|
|
125
169
|
return items;
|
|
126
170
|
}
|
|
127
171
|
|
|
172
|
+
function extractStandingCarries(text) {
|
|
173
|
+
if (!text) return [];
|
|
174
|
+
const lines = text.split("\n");
|
|
175
|
+
const start = lines.findIndex((l) => l.trim() === STANDING_CARRIES_HEADING);
|
|
176
|
+
if (start === -1) return [];
|
|
177
|
+
const carries = [];
|
|
178
|
+
for (let i = start + 1; i < lines.length; i++) {
|
|
179
|
+
const line = lines[i];
|
|
180
|
+
if (/^## /.test(line)) break;
|
|
181
|
+
const bullet = line.match(/^[-*] (.*)$/);
|
|
182
|
+
if (bullet) carries.push(bullet[1]);
|
|
183
|
+
}
|
|
184
|
+
return carries;
|
|
185
|
+
}
|
|
186
|
+
|
|
128
187
|
function countInbox(text) {
|
|
129
188
|
if (!text) return 0;
|
|
130
189
|
const lines = text.split("\n");
|
|
@@ -203,6 +262,7 @@ export function buildDigest({ wikiRoot, agent, today, fs }) {
|
|
|
203
262
|
cross_cutting: cross.map(mapPriority),
|
|
204
263
|
claims: active.map(mapClaim),
|
|
205
264
|
storyboard_items: parseStoryboardItems(storyboardText ?? "", agent),
|
|
265
|
+
standing_carries: extractStandingCarries(summaryText),
|
|
206
266
|
inbox_count: countInbox(summaryText),
|
|
207
267
|
summary_headroom: headroom(
|
|
208
268
|
summaryText ?? "",
|
package/src/cli-definition.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { runMemoCommand } from "./commands/memo.js";
|
|
2
2
|
import { runRefreshCommand } from "./commands/refresh.js";
|
|
3
|
+
import { runProductMixCommand } from "./commands/product-mix.js";
|
|
3
4
|
import { runInitCommand } from "./commands/init.js";
|
|
4
5
|
import { runPushCommand, runPullCommand } from "./commands/sync.js";
|
|
5
6
|
import { runBootCommand } from "./commands/boot.js";
|
|
@@ -218,6 +219,31 @@ export function createDefinition() {
|
|
|
218
219
|
},
|
|
219
220
|
},
|
|
220
221
|
},
|
|
222
|
+
{
|
|
223
|
+
name: "product-mix",
|
|
224
|
+
description:
|
|
225
|
+
"Emit the product-vs-internal mix of merged PRs as a `product_share` metric row",
|
|
226
|
+
handler: runProductMixCommand,
|
|
227
|
+
options: {
|
|
228
|
+
until: {
|
|
229
|
+
type: "string",
|
|
230
|
+
description: "Window end ISO date (default: today)",
|
|
231
|
+
},
|
|
232
|
+
since: {
|
|
233
|
+
type: "string",
|
|
234
|
+
description: "Window start ISO date (default: until − 7 days)",
|
|
235
|
+
},
|
|
236
|
+
run: {
|
|
237
|
+
type: "string",
|
|
238
|
+
description: "Run id recorded on the metric row (default: gh-live)",
|
|
239
|
+
},
|
|
240
|
+
repo: {
|
|
241
|
+
type: "string",
|
|
242
|
+
description: "owner/repo slug (default: origin remote)",
|
|
243
|
+
},
|
|
244
|
+
...wikiRootOpt,
|
|
245
|
+
},
|
|
246
|
+
},
|
|
221
247
|
{
|
|
222
248
|
name: "init",
|
|
223
249
|
description: "Bootstrap a wiki working tree and scaffold Active Claims",
|
|
@@ -262,6 +288,7 @@ export function createDefinition() {
|
|
|
262
288
|
"fit-wiki fix",
|
|
263
289
|
'fit-wiki memo --from staff-engineer --to security-engineer --message "audit d642ff0c"',
|
|
264
290
|
"fit-wiki refresh",
|
|
291
|
+
"fit-wiki product-mix",
|
|
265
292
|
"fit-wiki init",
|
|
266
293
|
"fit-wiki push",
|
|
267
294
|
"fit-wiki pull",
|
package/src/commands/claim.js
CHANGED
|
@@ -11,7 +11,11 @@ import { currentDayIso } from "../util/clock.js";
|
|
|
11
11
|
import { requireAgentFlag } from "../util/agent-flag.js";
|
|
12
12
|
import { resolveWikiRoot } from "../util/wiki-dir.js";
|
|
13
13
|
import { refusalEnvelope } from "../secret-gate.js";
|
|
14
|
-
import {
|
|
14
|
+
import {
|
|
15
|
+
AncestryRefusal,
|
|
16
|
+
PUSH_REASONS,
|
|
17
|
+
WikiPushFailure,
|
|
18
|
+
} from "../wiki-sync.js";
|
|
15
19
|
|
|
16
20
|
/** Non-zero envelope returned when the ancestry guard refused publication. */
|
|
17
21
|
const NOT_PUBLISHED = {
|
|
@@ -19,6 +23,16 @@ const NOT_PUBLISHED = {
|
|
|
19
23
|
code: 1,
|
|
20
24
|
};
|
|
21
25
|
|
|
26
|
+
// Failure reasons that, on the claim/release surfaces, are an unsafe-state
|
|
27
|
+
// refusal (D7/D9 family) rather than a saved-locally success (D1): the refusal
|
|
28
|
+
// fires before the local write is publishable, or leaves the tree unsafe for a
|
|
29
|
+
// later whole-tree sweep, so the surface must exit non-zero.
|
|
30
|
+
const UNSAFE_STATE_REASONS = new Set([
|
|
31
|
+
PUSH_REASONS.PRECONDITION,
|
|
32
|
+
PUSH_REASONS.RESIDUE_CONFLICT,
|
|
33
|
+
PUSH_REASONS.CONSERVATION,
|
|
34
|
+
]);
|
|
35
|
+
|
|
22
36
|
/** Build the not-published refusal message for the given guard refusal. */
|
|
23
37
|
function notPublishedMessage(err) {
|
|
24
38
|
return (
|
|
@@ -38,17 +52,23 @@ function memoryPath(runtime, options) {
|
|
|
38
52
|
}
|
|
39
53
|
|
|
40
54
|
/**
|
|
41
|
-
* Push the claim/release MEMORY.md change
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
55
|
+
* Push the claim/release MEMORY.md change and translate the honest outcome
|
|
56
|
+
* (the honest-CLI contract) into a command envelope, composed with the singleton merge
|
|
57
|
+
* discipline (the singleton merge discipline) and the secret/ancestry guards:
|
|
58
|
+
* - landed (grounded or re-applied) ⇒ `{ ok: true }`, success message printed;
|
|
59
|
+
* - `rejected`/`transport` ⇒ `{ ok: true }` with a saved-locally warning (the
|
|
60
|
+
* landed-locally row is complete; the session-end push is its retry);
|
|
61
|
+
* - `precondition`/`residue-conflict`/`conservation` ⇒ `{ ok: false, code: 1 }`
|
|
62
|
+
* (D7/D9 unsafe-state family — the row is not published and the tree may be
|
|
63
|
+
* left unsafe for a later whole-tree sweep);
|
|
64
|
+
* - a secret-gate refusal ⇒ `{ ok: false, code: 1 }` ({@link refusalEnvelope});
|
|
65
|
+
* - an {@link AncestryRefusal} is rethrown so `pushRowOrRefuse` maps it to the
|
|
66
|
+
* not-published non-zero envelope;
|
|
67
|
+
* - any other thrown error is a network/credential failure that degrades to
|
|
68
|
+
* "saved locally" (`{ ok: true }`).
|
|
46
69
|
*
|
|
47
70
|
* The `reapply` closure re-derives this row against the fresh tip if the
|
|
48
|
-
* landing contends, so a parallel writer's row is never erased.
|
|
49
|
-
* ancestry-guard refusal ({@link AncestryRefusal}) is rethrown so the caller's
|
|
50
|
-
* `pushRowOrRefuse` can map it to the not-published non-zero envelope; every
|
|
51
|
-
* other throw is a network/credential failure that degrades to "saved locally".
|
|
71
|
+
* landing contends, so a parallel writer's row is never erased.
|
|
52
72
|
*
|
|
53
73
|
* @param {object} wikiSync - The WikiSync collaborator (may be absent in tests).
|
|
54
74
|
* @param {object} runtime - The runtime bag (for stdout/stderr).
|
|
@@ -65,37 +85,54 @@ async function pushWiki(wikiSync, runtime, message, reapply) {
|
|
|
65
85
|
// claim/release contract is a 1-line MEMORY.md change; the pathspec keeps
|
|
66
86
|
// foreign uncommitted files from parallel writers out of the commit. The
|
|
67
87
|
// `reapply` closure re-derives this row against the fresh tip if the landing
|
|
68
|
-
// contends, so a parallel writer's row is never erased.
|
|
88
|
+
// contends (the singleton merge discipline), so a parallel writer's row is never erased.
|
|
69
89
|
result = await wikiSync.commitAndPush(message, ["MEMORY.md"], { reapply });
|
|
70
90
|
} catch (err) {
|
|
71
|
-
// An ancestry-guard refusal pierces the saved-locally degradation:
|
|
72
|
-
//
|
|
73
|
-
// other failure is a network/credential failure: preserve fire-and-forget
|
|
74
|
-
// "saved locally" — the change is on disk and the command still succeeds.
|
|
91
|
+
// An ancestry-guard refusal pierces the saved-locally degradation: rethrow
|
|
92
|
+
// so pushRowOrRefuse maps it to the not-published non-zero envelope.
|
|
75
93
|
if (err instanceof AncestryRefusal) throw err;
|
|
94
|
+
if (err instanceof WikiPushFailure) {
|
|
95
|
+
// D7/D9 unsafe-state family: the row is not published and the tree may be
|
|
96
|
+
// left unsafe for a later sweep — fail the command closed (non-zero).
|
|
97
|
+
if (UNSAFE_STATE_REASONS.has(err.reason)) {
|
|
98
|
+
runtime.proc.stderr.write(`${err.message}\n`);
|
|
99
|
+
return { ok: false, code: 1 };
|
|
100
|
+
}
|
|
101
|
+
// rejected / transport: the local row landed; warn and keep zero exit.
|
|
102
|
+
runtime.proc.stderr.write(
|
|
103
|
+
`saved locally — not yet visible to parallel sessions (${err.reason}): ${err.message}\n`,
|
|
104
|
+
);
|
|
105
|
+
return { ok: true };
|
|
106
|
+
}
|
|
107
|
+
// Any other failure: preserve fire-and-forget "saved locally" — the change
|
|
108
|
+
// is on disk and the command still succeeds.
|
|
76
109
|
createLogger("wiki", runtime).warn(
|
|
77
110
|
"claim",
|
|
78
111
|
`push failed (saved locally): ${err.message}`,
|
|
79
112
|
);
|
|
80
113
|
return { ok: true };
|
|
81
114
|
}
|
|
115
|
+
// A secret-gate refusal fails the command closed; a grounded-landed or a
|
|
116
|
+
// re-applied push reports success.
|
|
82
117
|
const refusal = refusalEnvelope(runtime, result);
|
|
83
118
|
if (refusal) return refusal;
|
|
84
|
-
if (result.
|
|
119
|
+
if (result.landed || result.pushed) {
|
|
120
|
+
runtime.proc.stdout.write("push: committed and pushed\n");
|
|
121
|
+
}
|
|
85
122
|
return { ok: true };
|
|
86
123
|
}
|
|
87
124
|
|
|
88
125
|
/**
|
|
89
126
|
* Push a written claim/release row, mapping an ancestry-guard refusal to the
|
|
90
|
-
* not-published non-zero envelope and any other outcome to `
|
|
91
|
-
* row is already written to MEMORY.md; on refusal it stays as an
|
|
92
|
-
* working-tree change. The `reapply` closure re-derives the same
|
|
93
|
-
* the fresh tip when the landing contends.
|
|
127
|
+
* not-published non-zero envelope and any other outcome to `pushWiki`'s
|
|
128
|
+
* envelope. The row is already written to MEMORY.md; on refusal it stays as an
|
|
129
|
+
* uncommitted working-tree change. The `reapply` closure re-derives the same
|
|
130
|
+
* row against the fresh tip when the landing contends.
|
|
94
131
|
*/
|
|
95
132
|
async function pushRowOrRefuse(wikiSync, runtime, message, reapply) {
|
|
96
133
|
try {
|
|
97
|
-
// Propagate pushWiki's envelope so a secret-gate
|
|
98
|
-
// fails the command closed; a clean push returns { ok: true }.
|
|
134
|
+
// Propagate pushWiki's envelope so a secret-gate or unsafe-state refusal
|
|
135
|
+
// ({ ok: false }) fails the command closed; a clean push returns { ok: true }.
|
|
99
136
|
return await pushWiki(wikiSync, runtime, message, reapply);
|
|
100
137
|
} catch (err) {
|
|
101
138
|
if (err instanceof AncestryRefusal) {
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { addDays } from "@forwardimpact/libutil";
|
|
2
|
+
import { createLogger } from "@forwardimpact/libtelemetry";
|
|
3
|
+
import { createScriptConfig } from "@forwardimpact/libconfig";
|
|
4
|
+
import { parseRepoSlug } from "../issue-list-renderer.js";
|
|
5
|
+
import { currentDayIso } from "../util/clock.js";
|
|
6
|
+
import { resolveProjectRoot } from "../util/wiki-dir.js";
|
|
7
|
+
|
|
8
|
+
// Resolve the monorepo's `owner/repo` slug the way `refresh.js` does: an
|
|
9
|
+
// explicit `FIT_GH_REPO` env override (sandbox proxy URLs), else the origin
|
|
10
|
+
// remote parsed via the injected git client. Returns null when nothing
|
|
11
|
+
// parseable is found, in which case `gh` falls back to its own cwd resolution.
|
|
12
|
+
async function deriveRepo(gitClient, cwd, env) {
|
|
13
|
+
if (env.FIT_GH_REPO) return env.FIT_GH_REPO;
|
|
14
|
+
if (!gitClient) return null;
|
|
15
|
+
try {
|
|
16
|
+
const url = await gitClient.remoteGetUrl("origin", { cwd });
|
|
17
|
+
return parseRepoSlug(url);
|
|
18
|
+
} catch {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// A missing token is non-fatal: `gh` may still resolve ambient auth, and a
|
|
24
|
+
// hard fetch failure downstream collapses to a logged warning and no row.
|
|
25
|
+
async function resolveToken() {
|
|
26
|
+
try {
|
|
27
|
+
return (await createScriptConfig("wiki")).ghToken();
|
|
28
|
+
} catch {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// `gh pr list` returns at most this many PRs; a window that hits the cap is
|
|
34
|
+
// truncated, so the caller warns rather than silently undercounting.
|
|
35
|
+
const FETCH_LIMIT = 200;
|
|
36
|
+
|
|
37
|
+
// Fetch merged PRs in `[since, until]` and return their parsed JSON, or null on
|
|
38
|
+
// any failure (non-zero exit or unparseable stdout) so the caller emits no row.
|
|
39
|
+
async function fetchMergedPrs({ runtime, cwd, repo, since, until, token }) {
|
|
40
|
+
const args = ["pr", "list", "--base", "main"];
|
|
41
|
+
if (repo) args.push("--repo", repo);
|
|
42
|
+
args.push(
|
|
43
|
+
"--json",
|
|
44
|
+
"number,labels,mergedAt",
|
|
45
|
+
"--search",
|
|
46
|
+
`merged:${since}..${until}`,
|
|
47
|
+
"--limit",
|
|
48
|
+
String(FETCH_LIMIT),
|
|
49
|
+
);
|
|
50
|
+
const env = token
|
|
51
|
+
? { ...runtime.proc.env, GH_TOKEN: token }
|
|
52
|
+
: runtime.proc.env;
|
|
53
|
+
const result = await runtime.subprocess.run("gh", args, { cwd, env });
|
|
54
|
+
if (result.exitCode !== 0) return null;
|
|
55
|
+
try {
|
|
56
|
+
return JSON.parse(result.stdout || "[]");
|
|
57
|
+
} catch {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Tally merged PRs by their classification label. A PR with neither label is
|
|
63
|
+
// unlabeled; `product` wins if both are somehow present.
|
|
64
|
+
function countByLabel(prs) {
|
|
65
|
+
const counts = { product: 0, internal: 0, unlabeled: 0 };
|
|
66
|
+
for (const pr of prs) {
|
|
67
|
+
const names = (pr.labels || []).map((l) => l.name);
|
|
68
|
+
if (names.includes("product")) counts.product++;
|
|
69
|
+
else if (names.includes("internal")) counts.internal++;
|
|
70
|
+
else counts.unlabeled++;
|
|
71
|
+
}
|
|
72
|
+
return counts;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Emit the product-vs-internal mix of merged PRs as a `product_share` metric
|
|
77
|
+
* row. Counts PRs merged in `[since, until]` by their `product` / `internal`
|
|
78
|
+
* label and appends `product_share = round(product / (product + internal) *
|
|
79
|
+
* 100)` to `wiki/metrics/product-mix/<YYYY>.csv` via the `fit-xmr record` write
|
|
80
|
+
* path. Deterministic — re-running over the same merged PRs yields the same
|
|
81
|
+
* value. A window with no labeled merged PRs emits no row (avoids a 0/0 ratio).
|
|
82
|
+
*/
|
|
83
|
+
export async function runProductMixCommand(ctx) {
|
|
84
|
+
const { runtime, gitClient } = ctx.deps;
|
|
85
|
+
const options = ctx.options;
|
|
86
|
+
const logger = createLogger("wiki", runtime);
|
|
87
|
+
const cwd = resolveProjectRoot(runtime);
|
|
88
|
+
|
|
89
|
+
const until = options.until || currentDayIso(runtime);
|
|
90
|
+
const since = options.since || addDays(until, -7);
|
|
91
|
+
const run = options.run || "gh-live";
|
|
92
|
+
const repo =
|
|
93
|
+
options.repo || (await deriveRepo(gitClient, cwd, runtime.proc.env));
|
|
94
|
+
const token = await resolveToken();
|
|
95
|
+
|
|
96
|
+
const prs = await fetchMergedPrs({ runtime, cwd, repo, since, until, token });
|
|
97
|
+
if (prs === null) {
|
|
98
|
+
logger.warn("product-mix", `gh pr list failed for ${since}..${until}`);
|
|
99
|
+
return { ok: true };
|
|
100
|
+
}
|
|
101
|
+
if (prs.length >= FETCH_LIMIT) {
|
|
102
|
+
logger.warn(
|
|
103
|
+
"product-mix",
|
|
104
|
+
`window ${since}..${until} hit the ${FETCH_LIMIT}-PR fetch cap; product_share may undercount`,
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const { product, internal, unlabeled } = countByLabel(prs);
|
|
109
|
+
const total = product + internal;
|
|
110
|
+
if (total === 0) {
|
|
111
|
+
logger.info(
|
|
112
|
+
"product-mix",
|
|
113
|
+
`no labeled merged PRs in ${since}..${until}; emitting no row`,
|
|
114
|
+
);
|
|
115
|
+
return { ok: true };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const share = Math.round((product / total) * 100);
|
|
119
|
+
const recordArgs = [
|
|
120
|
+
"fit-xmr",
|
|
121
|
+
"record",
|
|
122
|
+
"--skill",
|
|
123
|
+
"product-mix",
|
|
124
|
+
"--metric",
|
|
125
|
+
"product_share",
|
|
126
|
+
"--value",
|
|
127
|
+
String(share),
|
|
128
|
+
"--unit",
|
|
129
|
+
"pct",
|
|
130
|
+
"--date",
|
|
131
|
+
until,
|
|
132
|
+
"--run",
|
|
133
|
+
run,
|
|
134
|
+
"--note",
|
|
135
|
+
`product=${product} internal=${internal} unlabeled=${unlabeled} window=${since}..${until}`,
|
|
136
|
+
"--event-type",
|
|
137
|
+
"kata-shift",
|
|
138
|
+
];
|
|
139
|
+
if (options["wiki-root"]) {
|
|
140
|
+
recordArgs.push("--wiki-root", options["wiki-root"]);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const recordResult = await runtime.subprocess.run("npx", recordArgs, { cwd });
|
|
144
|
+
if (recordResult.exitCode !== 0) {
|
|
145
|
+
logger.warn("product-mix", "fit-xmr record failed");
|
|
146
|
+
}
|
|
147
|
+
return { ok: true };
|
|
148
|
+
}
|