Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
8f9625f
QEP-2: Standard GitHub label set and labelling policy
mmcky Jun 17, 2026
b84605b
QEP-2: fold in the #324 decisions
mmcky Jun 17, 2026
3e21fa9
Merge main into qep-0002: adopt Type/Status/Version index and lowerca…
mmcky Jul 23, 2026
d619872
QEP-2: field-report revision — security modifier, yml appendix as sou…
mmcky Jul 23, 2026
988f47e
QEP-2: Summary covers the full status-label retirement (Copilot review)
mmcky Jul 23, 2026
392b279
QEP-2: automation policy — at-most-one diagnostic, origin/task labels…
mmcky Jul 23, 2026
c9c572b
QEP-2: Related cross-reference — header-table row, frontmatter mirror…
mmcky Jul 23, 2026
5e7c8c1
QEP-2: fix stale core-label count in Rollout (18 → 19); close grey-ba…
mmcky Jul 23, 2026
dea698c
QEP-2: tier-neutral core descriptions; build-failure scope clarified
mmcky Jul 23, 2026
091eeea
QEP-2: self-contained — QEP-4 hooks move to QEP-4's acceptance PR
mmcky Jul 23, 2026
27f1a84
QEP-2: fold in the triage-pass field-report outcomes
mmcky Jul 23, 2026
16559e4
QEP-2: Rollout states mechanism and guarantees; execution moves to a …
mmcky Jul 23, 2026
7bdd104
QEP-2: Rollout in normative mood - obligations, not predictions
mmcky Jul 23, 2026
e8728ff
QEP-1 v2 (carried by QEP-2): rename the Rollout section to Adoption
mmcky Jul 23, 2026
2a3d020
QEP-2: Motivation states the case, not the history
mmcky Jul 23, 2026
ea3b943
QEP-2: Motivation leads with common meaning
mmcky Jul 23, 2026
0d855bb
QEP-2: discuss swatch 💗 → 🟪 — swatches mark colour family, not sentiment
mmcky Jul 23, 2026
2bf6f0f
QEP-2: meta joins the core-19 scope, keeping its unique local labels
mmcky Jul 23, 2026
29b5518
QEP-2: Alternatives keep the merits, drop the archaeology
mmcky Jul 23, 2026
0d3f0ff
QEP-2: final pass - Summary states the shape, wording and wrap cleanups
mmcky Jul 23, 2026
f865f26
QEP-2: software extension adds refactor; infrastructure owns behaviou…
mmcky Jul 24, 2026
5758c91
QEP-2: priority is judged repo-wide — a milestone doesn't re-centre t…
mmcky Jul 24, 2026
16754a5
QEP-2: grouping examples span recurring programs and one-shot campaigns
mmcky Jul 24, 2026
adcb0db
QEP-2: dependencies are for hard blocks; non-issue blockers go in the…
mmcky Jul 28, 2026
ef9e847
QEP-2: adoption converges the corpus; migration reports are the evide…
mmcky Jul 28, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
146 changes: 146 additions & 0 deletions .github/scripts/check-labels.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
// Machine-readable appendix checks (see QEP-2 § Machine-readable appendix):
// 1. Every qeps/qep-NNNN-*.yml companion stays in lockstep with the label
// tables in its owning QEP: same label names, and per label the same
// colour, description, and scope (core vs the lecture / software extensions).
// 2. Once the owning QEP is Accepted, a change to its companion yml is a
// substantive amendment and must bump the QEP's `version` in the same PR.
// Run by .github/workflows/qep-checks.yml. Exits non-zero on any failure.
import { readFileSync, readdirSync } from 'node:fs';
import { execSync } from 'node:child_process';
import { FRONTMATTER, QEP_DIR, parseQep, qepFiles } from './qeps.mjs';

const base = process.env.BASE_REF || 'main';
const errors = [];

function stripQuotes(s) {
return s.replace(/^["']|["']$/g, '');
}

// Parse a companion yml: a `labels:` list whose items are flat scalar maps.
// Deliberately minimal — the appendix format is constrained by construction.
function parseLabelsYml(path) {
const labels = [];
let current;
for (const raw of readFileSync(path, 'utf8').split('\n')) {
const line = raw.replace(/\s+$/, '');
if (/^\s*(#|$)/.test(line)) continue;
let m = line.match(/^\s*-\s+name:[ \t]*(.*)$/);
if (m) {
current = { name: stripQuotes(m[1]) };
labels.push(current);
continue;
}
m = line.match(/^\s+([\w-]+):[ \t]*(.*)$/);
if (m && current) current[m[1]] = stripQuotes(m[2]);
}
return labels;
}

// Parse the label tables out of a QEP body: any table row whose first cell is
// a backticked name and whose second cell carries a `#hex` colour. Scope comes
// from the nearest preceding bold group heading ("Lecture extension — ...").
function parseLabelTables(path) {
const labels = [];
let scope = 'core';
for (const line of readFileSync(path, 'utf8').split('\n')) {
const heading = line.match(/^\*\*(.+?)\*\*$/);
if (heading) {
scope = /^Lecture extension/i.test(heading[1])
? 'lecture'
: /^Software extension/i.test(heading[1])
? 'software'
: 'core';
continue;
}
const m = line.match(/^\|\s*`([^`]+)`\s*\|[^|`]*`#([0-9a-fA-F]{6})`\s*\|([^|]*)\|/);
if (m) labels.push({ name: m[1], color: m[2].toLowerCase(), description: m[3].trim(), scope });
}
return labels;
}

// The QEP file a companion belongs to, matched on the qep-NNNN- prefix.
function owningQep(ymlPath) {
const num = ymlPath.match(/qep-(\d{4})-/)?.[1];
return qepFiles().find((p) => p.includes(`/qep-${num}-`));
}

// Frontmatter `status`/`version` of a file on the base branch (undefined if new).
function baseFrontmatter(path) {
let text;
try {
text = execSync(`git show "origin/${base}:${path}"`, {
stdio: ['pipe', 'pipe', 'ignore'],
}).toString();
} catch {
return undefined;
}
const block = text.match(FRONTMATTER)?.[1] ?? '';
return {
status: block.match(/^status:[ \t]*(.*?)[ \t]*$/m)?.[1],
version: block.match(/^version:[ \t]*(\d+)/m)?.[1],
};
}

function baseFile(path) {
try {
return execSync(`git show "origin/${base}:${path}"`, {
stdio: ['pipe', 'pipe', 'ignore'],
}).toString();
} catch {
return undefined;
}
}

const companions = readdirSync(QEP_DIR)
.filter((f) => /^qep-\d{4}-.*\.yml$/.test(f))
.map((f) => `${QEP_DIR}/${f}`);

for (const ymlPath of companions) {
const qepPath = owningQep(ymlPath);
if (!qepPath) {
errors.push(`${ymlPath}: no owning QEP file found for this companion`);
continue;
}

// 1. The yml and the QEP's tables describe the same label set.
const yml = new Map(parseLabelsYml(ymlPath).map((l) => [l.name, l]));
const tables = new Map(parseLabelTables(qepPath).map((l) => [l.name, l]));
for (const name of yml.keys()) {
if (!tables.has(name)) errors.push(`${qepPath}: tables are missing \`${name}\` (present in ${ymlPath})`);
}
for (const [name, t] of tables) {
const y = yml.get(name);
if (!y) {
errors.push(`${ymlPath}: missing \`${name}\` (present in ${qepPath} tables)`);
continue;
}
if ((y.color ?? '').toLowerCase() !== t.color) {
errors.push(`\`${name}\`: colour "${y.color}" (${ymlPath}) != "#${t.color}" (${qepPath})`);
}
if (y.description !== t.description) {
errors.push(`\`${name}\`: description differs between ${ymlPath} and ${qepPath}`);
}
if ((y.scope ?? 'core') !== t.scope) {
errors.push(`\`${name}\`: scope "${y.scope}" (${ymlPath}) != "${t.scope}" (${qepPath})`);
}
}

// 2. Post-acceptance, a yml change is substantive: `version` must move with it.
const before = baseFile(ymlPath);
const fm = baseFrontmatter(qepPath);
if (before !== undefined && before !== readFileSync(ymlPath, 'utf8') && fm?.status === 'Accepted') {
const { version } = parseQep(qepPath);
if (String(version ?? '') === (fm.version ?? '')) {
errors.push(
`${ymlPath}: changed on an Accepted QEP without bumping ${qepPath} version ` +
`(a machine-readable appendix change is substantive under QEP-1)`,
);
}
}
}

if (errors.length) {
console.error('Label appendix checks failed:\n' + errors.map((e) => ` - ${e}`).join('\n'));
process.exit(1);
}
console.log(`Label appendix checks passed (${companions.length} companion file(s)).`);
37 changes: 37 additions & 0 deletions .github/scripts/check.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
// 3. The README Type/Status/Version columns match each QEP's frontmatter.
// Run by .github/workflows/qep-checks.yml. Exits non-zero on any failure.
import { execSync } from 'node:child_process';
import { readFileSync } from 'node:fs';
import { FRONTMATTER, parseQep, qepFiles, readIndex, versionCell } from './qeps.mjs';

const base = process.env.BASE_REF || 'main';
Expand Down Expand Up @@ -103,6 +104,42 @@ for (const path of qepFiles()) {
}
}

// 4. `related` frontmatter stays in lockstep with the header table's
// **Related** row (the primary human record). Both absent is fine; one
// without the other, a self-reference, or differing QEP sets is an error.
// A related QEP whose file is not on this branch only warns — in-flight
// drafts may reference each other across branches; once both merge the
// warning disappears.
for (const path of qepFiles()) {
const q = parseQep(path);
const row = readFileSync(path, 'utf8').match(/^\|\s*\*\*Related\*\*\s*\|(.*)\|\s*$/m);
const inTable = row ? [...row[1].matchAll(/qep-(\d{4})/g)].map((m) => Number(m[1])) : [];
const inFm = q.related ?? [];
if (!row && inFm.length === 0) continue;
if (!row) {
errors.push(`${path}: has related: [...] frontmatter but no **Related** row in the header table`);
continue;
}
if (inFm.length === 0) {
errors.push(`${path}: has a **Related** header-table row but no related: [...] frontmatter`);
continue;
}
const fmSet = [...new Set(inFm)].sort((a, b) => a - b);
const tableSet = [...new Set(inTable)].sort((a, b) => a - b);
if (JSON.stringify(fmSet) !== JSON.stringify(tableSet)) {
errors.push(
`${path}: related frontmatter [${fmSet.join(', ')}] != header-table Related row [${tableSet.join(', ')}]`,
);
}
for (const n of fmSet) {
if (n === q.qep) {
errors.push(`${path}: related lists itself (QEP-${n})`);
} else if (!qepFiles().some((p) => p.includes(`/qep-${String(n).padStart(4, '0')}-`))) {
console.warn(`WARN ${path}: related QEP-${n} not on this branch (in-flight draft?)`);
}
}
}

if (errors.length) {
console.error('QEP checks failed:\n' + errors.map((e) => ` - ${e}`).join('\n'));
process.exit(1);
Expand Down
12 changes: 12 additions & 0 deletions .github/scripts/qeps.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,17 @@ export function parseQep(path) {
version = Number(v[1]);
}

// Optional `related: [N, ...]` — QEPs this one is paired with. The header
// table's **Related** row is the primary human record; check.mjs keeps the
// two in lockstep. Editing `related` is editorial (no normative content).
const rawRelated = field(block, 'related');
let related;
if (rawRelated !== undefined && rawRelated !== '') {
const r = rawRelated.match(/^\[([0-9,\s]*)\]$/);
if (!r) throw new Error(`${path}: malformed "related: ${rawRelated}" (expected e.g. [2, 4])`);
related = r[1].split(',').map((s) => s.trim()).filter(Boolean).map(Number);
}

// `version-hash` may carry a trailing "# stamped by CI" signpost comment.
const rawHash = field(block, 'version-hash');
let hash;
Expand All @@ -60,6 +71,7 @@ export function parseQep(path) {
title: stripQuotes(field(block, 'title')),
status: stripQuotes(field(block, 'status')),
type: stripQuotes(field(block, 'type')),
related,
version,
hash,
};
Expand Down
4 changes: 4 additions & 0 deletions .github/workflows/qep-checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,7 @@ jobs:
run: node .github/scripts/check.mjs
env:
BASE_REF: ${{ github.base_ref }}
- name: Run label appendix checks
run: node .github/scripts/check-labels.mjs
env:
BASE_REF: ${{ github.base_ref }}
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ need a QEP.

| QEP | Title | Type | Status | Version |
|-----|-------|------|--------|---------|
| [QEP-1](qeps/qep-0001-purpose-and-process.md) | QEP Purpose and Process | process | Accepted | v1 |
| [QEP-1](qeps/qep-0001-purpose-and-process.md) | QEP Purpose and Process | process | Accepted | v2 |
| [QEP-2](qeps/qep-0002-standard-github-labels.md) | Standard GitHub Label Set and Labelling Policy | standard | Draft | – |

QEPs that set an ongoing rule are **maintained in place**: a substantive amendment bumps
the QEP's `version` (shown above) under the same review process, rather than superseding
Expand Down
16 changes: 11 additions & 5 deletions qeps/qep-0001-purpose-and-process.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ title: QEP Purpose and Process
author: "@mmcky"
status: Accepted
type: process
version: 1
version: 2
version-hash: 0bff77a # stamped by CI; do not edit
created: 2026-06-16
discussion: https://github.com/QuantEcon/meta/issues/325
Expand All @@ -19,7 +19,7 @@ discussion: https://github.com/QuantEcon/meta/issues/325
| **Author** | @mmcky |
| **Status** | Accepted |
| **Type** | process |
| **Version** | 1 |
| **Version** | 2 |
| **Created** | 2026-06-16 |
| **Discussion** | [QuantEcon/meta#325](https://github.com/QuantEcon/meta/issues/325) |

Expand Down Expand Up @@ -155,7 +155,7 @@ From `v1` onward a sibling `version-hash` field carries the short commit hash th
anchors the revision to git history:

```yaml
version: 1
version: 2
version-hash: a1b2c3d # stamped by CI; do not edit
```

Expand Down Expand Up @@ -240,7 +240,7 @@ Each QEP is a Markdown file with YAML frontmatter (`qep`, `title`, `author`, `st
`type`, `created`, `discussion` — plus `version` and its CI-stamped `version-hash`, which
sit just after `type` once the QEP is first amended) followed by the sections in
[`qeps/template.md`](../qeps/template.md): **Summary, Motivation, Proposal, Alternatives
considered, Rollout**. The `type` field describes the **kind of content** the QEP
considered, Adoption**. The `type` field describes the **kind of content** the QEP
carries:

- **`standard`** — a normative spec or rule you conform to (a label schema, a style
Expand Down Expand Up @@ -279,7 +279,7 @@ type; a one-off *decision* is a `standard` if it sets an ongoing rule, or
instead, surfaced on the site by the theme's history feature and on GitHub by
history/blame.

## Rollout
## Adoption

1. **(v0) Establish the process.** Merge this QEP to set the process; re-record the
label-set decision
Expand All @@ -296,3 +296,9 @@ type; a one-off *decision* is a `standard` if it sets an ongoing rule, or
author-side steps in `AGENTS.md`; and set the repository to squash-merge only.
Adopting this mechanism is QEP-1's own first substantive amendment, so QEP-1
becomes **v1**.
3. **(v2) Rename this section: `Rollout` becomes `Adoption`.** The section states
what adopting a QEP requires — mechanism, tooling, and the guarantees any
implementation must honour — as obligations rather than a plan, so nothing in it
can be falsified by work not happening; sequenced execution (who does what, when)
belongs in a tracking issue. Applied first by QEP-2, whose acceptance PR carries
this amendment.
Loading
Loading