@ericrisco/rsc 1.1.3 → 1.1.4
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/manifest.json +1 -1
- package/package.json +1 -1
- package/scripts/lib/spec-gate.js +98 -0
- package/scripts/spec-gate.js +57 -4
package/manifest.json
CHANGED
package/package.json
CHANGED
package/scripts/lib/spec-gate.js
CHANGED
|
@@ -34,6 +34,10 @@ const UNCHECKED = [
|
|
|
34
34
|
'que ninguna sección contenga una suposición sin marcar (no es detectable por máquina; es el juicio que la fase existe para ejercer)',
|
|
35
35
|
'que el contenido de cada sección sea correcto, y no sólo presente',
|
|
36
36
|
'que los criterios de aceptación sean de verdad binarios',
|
|
37
|
+
// Added with the status check below, for the same reason the three above exist: a green that is
|
|
38
|
+
// narrower than the rule it appears to enforce is the decorative gate all over again.
|
|
39
|
+
'si una spec dice "implementada" sin nombrar commit, PR ni versión (no hay nada que ir a mirar)',
|
|
40
|
+
'si el trabajo de una spec que se declara sin aterrizar llegó por otra vía (git no lo distingue)',
|
|
37
41
|
];
|
|
38
42
|
|
|
39
43
|
function fold(s) {
|
|
@@ -120,3 +124,97 @@ export function specCompleteness(markdown) {
|
|
|
120
124
|
unchecked: UNCHECKED,
|
|
121
125
|
};
|
|
122
126
|
}
|
|
127
|
+
|
|
128
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
129
|
+
// Does the status still match the repository?
|
|
130
|
+
//
|
|
131
|
+
// The section check above asks whether a spec is COMPLETE. This asks whether it is still TRUE — and
|
|
132
|
+
// nothing asked that before, which is how seven of twenty-five statuses came to contradict the repo:
|
|
133
|
+
// one claiming "sin push ni PR" over work that had been in main for weeks, four sitting in
|
|
134
|
+
// `awaiting-approval` over work already published. Spec: 02-DOCS/wiki/sdd/specs/spec-status-drift.md
|
|
135
|
+
//
|
|
136
|
+
// The vocabulary below was extracted from the 25 real statuses, not imagined. That matters: an
|
|
137
|
+
// invented recognizer reaches for the bare word `implementada`, and the bare word is not checkable —
|
|
138
|
+
// it names nothing to go and look for. Only a commit, a PR or a released version do.
|
|
139
|
+
|
|
140
|
+
/** The claim shapes this recognizer knows. Anything else is silence, not a finding. */
|
|
141
|
+
export const CLAIM_KINDS = ['sha', 'pr', 'version', 'not-landed'];
|
|
142
|
+
|
|
143
|
+
// Deliberately absent: the bare words `implementada`, `desplegada`, `descartada`, and the phrase
|
|
144
|
+
// `no aprobada`. The first two name nothing to check. The last two look like landing claims and are
|
|
145
|
+
// not — `descartada` is a spec closed on purpose after being measured, `no aprobada` describes HOW
|
|
146
|
+
// approval happened (in autopilot, not item by item), not whether code shipped. Treating either as a
|
|
147
|
+
// landing claim would invent drift in the two best-closed specs of the set.
|
|
148
|
+
const SHA = /\b[0-9a-f]{7,40}\b/g;
|
|
149
|
+
const PR = /\bPR\s*#(\d+)/gi;
|
|
150
|
+
const VERSION = /\bpublicada en (\d+\.\d+\.\d+)/gi;
|
|
151
|
+
const NOT_LANDED = /\bsin push\b|\bsin PR\b|\bawaiting-approval\b/i;
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Pull the checkable claims out of a status string. Pure: no git, no I/O.
|
|
155
|
+
* @param {string} statusText
|
|
156
|
+
* @returns {Array<{kind: string, value: string, raw: string}>}
|
|
157
|
+
*/
|
|
158
|
+
export function statusClaims(statusText) {
|
|
159
|
+
const text = typeof statusText === 'string' ? statusText : '';
|
|
160
|
+
const claims = [];
|
|
161
|
+
for (const m of text.matchAll(PR)) claims.push({ kind: 'pr', value: m[1], raw: m[0] });
|
|
162
|
+
for (const m of text.matchAll(VERSION)) claims.push({ kind: 'version', value: m[1], raw: m[0] });
|
|
163
|
+
for (const m of text.matchAll(SHA)) claims.push({ kind: 'sha', value: m[0], raw: m[0] });
|
|
164
|
+
const neg = NOT_LANDED.exec(text);
|
|
165
|
+
if (neg) claims.push({ kind: 'not-landed', value: neg[0], raw: neg[0] });
|
|
166
|
+
return claims;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Judge each claim against the repository.
|
|
171
|
+
*
|
|
172
|
+
* Four verdicts and none of them collapse into another. `unverifiable` is the one that keeps this
|
|
173
|
+
* honest — a claim we could not check is not a claim that passed, and it is not drift either.
|
|
174
|
+
*
|
|
175
|
+
* `not-landed` is always handed to a human, and that is a deliberate retreat from what the spec
|
|
176
|
+
* first promised. No query distinguishes "never landed" from "landed by another route": the case
|
|
177
|
+
* that started this work names a commit that genuinely is not in main, while its deliverable has
|
|
178
|
+
* been there for weeks. Checking the named commit would bless the misleading status; inferring the
|
|
179
|
+
* other from anything else would be a heuristic inside a gate. So the deterministic part narrows,
|
|
180
|
+
* and only what it narrowed goes to judgement — principle 1, and the shape `knowledge-drift` already
|
|
181
|
+
* uses.
|
|
182
|
+
*
|
|
183
|
+
* @param {Array<object>} claims from `statusClaims`
|
|
184
|
+
* @param {?{hasCommit: Function, isAncestor: Function, hasPr: Function, hasTag: Function}} probe
|
|
185
|
+
* null when there is no repository to ask — every claim then reports unverifiable.
|
|
186
|
+
* @returns {Array<{kind: string, value: string, verdict: string, reason: string}>}
|
|
187
|
+
*/
|
|
188
|
+
export function checkClaims(claims, probe) {
|
|
189
|
+
return (claims || []).map((c) => {
|
|
190
|
+
if (c.kind === 'not-landed') {
|
|
191
|
+
return {
|
|
192
|
+
...c,
|
|
193
|
+
verdict: 'needs-human',
|
|
194
|
+
reason: 'git cannot tell "never landed" from "landed by another route" — look at whether the deliverable is in main, not at the branch',
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
if (!probe) return { ...c, verdict: 'unverifiable', reason: 'no repository to ask' };
|
|
198
|
+
try {
|
|
199
|
+
if (c.kind === 'sha') {
|
|
200
|
+
if (!probe.hasCommit(c.value)) {
|
|
201
|
+
return { ...c, verdict: 'unverifiable', reason: `commit ${c.value} is not in this repository` };
|
|
202
|
+
}
|
|
203
|
+
return probe.isAncestor(c.value)
|
|
204
|
+
? { ...c, verdict: 'holds', reason: `${c.value} is in main` }
|
|
205
|
+
: { ...c, verdict: 'stale', reason: `${c.value} is NOT in main` };
|
|
206
|
+
}
|
|
207
|
+
if (c.kind === 'pr') {
|
|
208
|
+
return probe.hasPr(c.value)
|
|
209
|
+
? { ...c, verdict: 'holds', reason: `PR #${c.value} is merged into main` }
|
|
210
|
+
: { ...c, verdict: 'stale', reason: `no merge of PR #${c.value} in main` };
|
|
211
|
+
}
|
|
212
|
+
return probe.hasTag(c.value)
|
|
213
|
+
? { ...c, verdict: 'holds', reason: `v${c.value} is tagged` }
|
|
214
|
+
: { ...c, verdict: 'stale', reason: `no v${c.value} tag in this repository` };
|
|
215
|
+
} catch (e) {
|
|
216
|
+
// Could not look. Saying `stale` here would invent a finding out of our own failure.
|
|
217
|
+
return { ...c, verdict: 'unverifiable', reason: e.message };
|
|
218
|
+
}
|
|
219
|
+
});
|
|
220
|
+
}
|
package/scripts/spec-gate.js
CHANGED
|
@@ -7,7 +7,8 @@
|
|
|
7
7
|
import { readFileSync, readdirSync } from 'node:fs';
|
|
8
8
|
import { join, dirname, basename } from 'node:path';
|
|
9
9
|
import { fileURLToPath } from 'node:url';
|
|
10
|
-
import {
|
|
10
|
+
import { execFileSync } from 'node:child_process';
|
|
11
|
+
import { specCompleteness, statusClaims, checkClaims } from './lib/spec-gate.js';
|
|
11
12
|
|
|
12
13
|
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
13
14
|
const DEFAULT_DIR = join(ROOT, '02-DOCS', 'wiki', 'sdd', 'specs');
|
|
@@ -22,6 +23,35 @@ function defaultTargets() {
|
|
|
22
23
|
}
|
|
23
24
|
}
|
|
24
25
|
|
|
26
|
+
// The repository, asked instead of assumed. Returns null when there is nothing to ask — a
|
|
27
|
+
// report-only instrument that crashes where it cannot look is worse than one that says so.
|
|
28
|
+
function gitProbe(root) {
|
|
29
|
+
const git = (args) => execFileSync('git', args, { cwd: root, stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim();
|
|
30
|
+
let main;
|
|
31
|
+
try {
|
|
32
|
+
git(['rev-parse', '--git-dir']);
|
|
33
|
+
// Prefer the remote tip — a stale local `main` reports merged work as missing. But CI checks out
|
|
34
|
+
// detached with no `origin/main` and no local `main`, and falling through to "no repository to
|
|
35
|
+
// ask" there means the check silently stops checking exactly where it runs unattended. HEAD is
|
|
36
|
+
// the honest last resort: on CI it IS the branch under test.
|
|
37
|
+
main = ['origin/main', 'main', 'HEAD'].find((ref) => {
|
|
38
|
+
try { git(['rev-parse', '--verify', ref]); return true; } catch { return false; }
|
|
39
|
+
});
|
|
40
|
+
if (!main) throw new Error('no ref to compare against');
|
|
41
|
+
} catch {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
return {
|
|
45
|
+
hasCommit: (sha) => { try { git(['cat-file', '-e', `${sha}^{commit}`]); return true; } catch { return false; } },
|
|
46
|
+
isAncestor: (sha) => { try { git(['merge-base', '--is-ancestor', sha, main]); return true; } catch { return false; } },
|
|
47
|
+
// The squash-merge subject carries `(#N)`; --fixed-strings so the parens are not a regex.
|
|
48
|
+
hasPr: (n) => { try { return git(['log', main, '--grep', `(#${n})`, '--fixed-strings', '-1', '--format=%H']) !== ''; } catch { return false; } },
|
|
49
|
+
hasTag: (v) => { try { return git(['tag', '-l', `v${v}`]) !== ''; } catch { return false; } },
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const statusOf = (text) => (/^status:[ \t]*(.*)$/m.exec(text) || [, ''])[1];
|
|
54
|
+
|
|
25
55
|
function main() {
|
|
26
56
|
const args = process.argv.slice(2);
|
|
27
57
|
const targets = args.length ? args : defaultTargets();
|
|
@@ -31,8 +61,21 @@ function main() {
|
|
|
31
61
|
}
|
|
32
62
|
|
|
33
63
|
let failed = 0;
|
|
64
|
+
let drifted = 0;
|
|
65
|
+
// One probe per repository, not one for the tool's own. A spec's claims are about the history of
|
|
66
|
+
// the project the spec belongs to — checking them against wherever this script happens to live
|
|
67
|
+
// gives the right answer only by coincidence, and the wrong one the moment the gate is pointed at
|
|
68
|
+
// another checkout.
|
|
69
|
+
const probes = new Map();
|
|
70
|
+
const probeFor = (path) => {
|
|
71
|
+
const dir = dirname(path);
|
|
72
|
+
if (!probes.has(dir)) probes.set(dir, gitProbe(dir));
|
|
73
|
+
return probes.get(dir);
|
|
74
|
+
};
|
|
75
|
+
let anyProbe = false;
|
|
34
76
|
for (const path of targets) {
|
|
35
|
-
const
|
|
77
|
+
const text = readFileSync(path, 'utf8');
|
|
78
|
+
const r = specCompleteness(text);
|
|
36
79
|
const name = basename(path);
|
|
37
80
|
if (r.ok) {
|
|
38
81
|
const typed = r.openPoints.length - r.untyped.length;
|
|
@@ -40,17 +83,27 @@ function main() {
|
|
|
40
83
|
if (r.untyped.length) {
|
|
41
84
|
console.log(` untyped (treated as open questions): ${r.untyped.length}`);
|
|
42
85
|
}
|
|
43
|
-
}
|
|
86
|
+
}
|
|
87
|
+
if (!r.ok) {
|
|
44
88
|
failed += 1;
|
|
45
89
|
console.log(`FAIL ${name}`);
|
|
46
90
|
if (r.missing.length) console.log(` missing section(s): ${r.missing.join(', ')}`);
|
|
47
91
|
if (r.empty.length) console.log(` section(s) with no content: ${r.empty.join(', ')}`);
|
|
48
92
|
}
|
|
93
|
+
// A status that still matches the repository prints nothing: the report grows only where there
|
|
94
|
+
// is something to act on (P5).
|
|
95
|
+
const probe = probeFor(path);
|
|
96
|
+
anyProbe = anyProbe || Boolean(probe);
|
|
97
|
+
for (const v of checkClaims(statusClaims(statusOf(text)), probe)) {
|
|
98
|
+
if (v.verdict === 'holds') continue;
|
|
99
|
+
if (v.verdict === 'stale') drifted += 1;
|
|
100
|
+
console.log(` status ${v.verdict.toUpperCase()}: ${v.raw} — ${v.reason}`);
|
|
101
|
+
}
|
|
49
102
|
}
|
|
50
103
|
|
|
51
104
|
// A green here is narrower than the rule it enforces, so it says so every run rather than
|
|
52
105
|
// letting the reader assume the gate covered more than it did.
|
|
53
|
-
console.log(`\n${targets.length - failed}/${targets.length} pass. Not checked by this gate:`);
|
|
106
|
+
console.log(`\n${targets.length - failed}/${targets.length} pass, ${drifted} status claim(s) contradicted by the repo${anyProbe ? '' : ' (no repository to ask)'}. Not checked by this gate:`);
|
|
54
107
|
for (const u of specCompleteness('').unchecked) console.log(` - ${u}`);
|
|
55
108
|
if (failed) process.exit(1);
|
|
56
109
|
}
|