@ericrisco/rsc 1.0.3 → 1.0.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/doctor.js +3 -2
- package/scripts/rsc.js +31 -10
- package/skills/review/SKILL.md +3 -2
- package/skills/ship/SKILL.md +4 -2
- package/targets/sello.mjs +39 -4
package/manifest.json
CHANGED
package/package.json
CHANGED
package/scripts/doctor.js
CHANGED
|
@@ -6,7 +6,7 @@ import { readState } from './lib/state.js';
|
|
|
6
6
|
import { loadManifest } from './lib/manifest.js';
|
|
7
7
|
import { listBackups } from './lib/backups.js';
|
|
8
8
|
import { SDD_GATE_TEXT } from '../targets/hook-once.mjs';
|
|
9
|
-
import { isEnabled, checkSello, readSello, countFindings,
|
|
9
|
+
import { isEnabled, checkSello, readSello, countFindings, readEffectiveConfig, validateRiskConfig } from '../targets/sello.mjs';
|
|
10
10
|
|
|
11
11
|
// The sello's health, surfaced where the user already looks (spec: non-blocking
|
|
12
12
|
// findings live in the project and are SUMMARIZED here, never nagged about).
|
|
@@ -19,6 +19,7 @@ function selloStatus(root) {
|
|
|
19
19
|
const verdict = checkSello(root);
|
|
20
20
|
const out = {
|
|
21
21
|
enabled: true,
|
|
22
|
+
decidedBy: readEffectiveConfig(root)?.scope,
|
|
22
23
|
check: verdict.code,
|
|
23
24
|
status: sello.missing ? 'none' : sello.corrupt ? 'corrupt' : sello.status,
|
|
24
25
|
nonBlockingFindings: countFindings(root),
|
|
@@ -29,7 +30,7 @@ function selloStatus(root) {
|
|
|
29
30
|
out.note = 'ship-guard branch-hygiene rules are opted out (.rsc/.no-ship-guard); the sello itself still enforces.';
|
|
30
31
|
}
|
|
31
32
|
try {
|
|
32
|
-
const { lowered } = validateRiskConfig(
|
|
33
|
+
const { lowered } = validateRiskConfig(readEffectiveConfig(root) || {});
|
|
33
34
|
if (lowered.length) out.loweredClasses = lowered;
|
|
34
35
|
} catch (e) { out.configError = e.message; }
|
|
35
36
|
return out;
|
package/scripts/rsc.js
CHANGED
|
@@ -340,7 +340,12 @@ async function main() {
|
|
|
340
340
|
switch (sub) {
|
|
341
341
|
case 'on':
|
|
342
342
|
case 'off': {
|
|
343
|
-
|
|
343
|
+
// --global writes the user-scope switch (every project inherits it);
|
|
344
|
+
// without it, the project switch, which always wins over the global one.
|
|
345
|
+
const isGlobal = argv.includes('--global');
|
|
346
|
+
const target = isGlobal ? S.globalConfigPath() : paths.config;
|
|
347
|
+
const cfg = (isGlobal ? S.readGlobalConfig() : S.readConfig(root)) || {};
|
|
348
|
+
delete cfg.scope; // derived, never persisted
|
|
344
349
|
// `off` never validates: it is the recovery advertised by every deny
|
|
345
350
|
// message, so a broken config must not be able to lock it away.
|
|
346
351
|
if (sub === 'on') {
|
|
@@ -348,15 +353,22 @@ async function main() {
|
|
|
348
353
|
}
|
|
349
354
|
const { writeFileSync, mkdirSync } = await import('node:fs');
|
|
350
355
|
const { dirname } = await import('node:path');
|
|
351
|
-
mkdirSync(dirname(
|
|
352
|
-
writeFileSync(
|
|
356
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
357
|
+
writeFileSync(target, JSON.stringify({ ...cfg, enabled: sub === 'on' }, null, 2) + '\n');
|
|
358
|
+
const where = isGlobal ? 'for every project (global)' : 'for this project';
|
|
353
359
|
say(sub === 'on'
|
|
354
|
-
?
|
|
355
|
-
:
|
|
360
|
+
? `✅ sello ON ${where}. Risk-0 changes (docs/copy) still pass silently; everything else needs a review before commit/push/PR.`
|
|
361
|
+
: `✅ sello OFF ${where}. Delivery behaves exactly as before.`);
|
|
362
|
+
if (isGlobal) {
|
|
363
|
+
const p = S.readConfig(root);
|
|
364
|
+
if (p && p.enabled !== undefined && p.enabled !== (sub === 'on')) {
|
|
365
|
+
say(` ⚠️ this project overrides it (project = ${p.enabled ? 'on' : 'off'}); the project switch always wins. Change it with \`sello ${p.enabled ? 'off' : 'on'}\` here.`);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
356
368
|
return;
|
|
357
369
|
}
|
|
358
370
|
case 'freeze': {
|
|
359
|
-
const cfg = S.
|
|
371
|
+
const cfg = S.readEffectiveConfig(root);
|
|
360
372
|
const candidate = S.computeCandidate(root, cfg);
|
|
361
373
|
if (!candidate) { say(S.MESSAGES.noTrunk()); process.exitCode = 1; return; }
|
|
362
374
|
const paths2 = Object.keys(candidate.files);
|
|
@@ -374,7 +386,7 @@ async function main() {
|
|
|
374
386
|
case 'block': {
|
|
375
387
|
const sello = S.readSello(root);
|
|
376
388
|
if (sello.missing || sello.corrupt || !sello.status) { say(S.MESSAGES.notFrozen()); process.exitCode = 1; return; }
|
|
377
|
-
const cfg = S.
|
|
389
|
+
const cfg = S.readEffectiveConfig(root);
|
|
378
390
|
const current = S.computeCandidate(root, cfg);
|
|
379
391
|
const changed = current && Object.keys({ ...sello.files, ...current.files })
|
|
380
392
|
.some((p) => sello.files[p] !== current.files[p]);
|
|
@@ -415,7 +427,7 @@ async function main() {
|
|
|
415
427
|
case 'budget-check': {
|
|
416
428
|
const sello = S.readSello(root);
|
|
417
429
|
if (sello.missing || sello.corrupt || !sello.budget) { say('sello: no declared budget. Recover: run `rsc sello budget --lines <N>` BEFORE fixing.'); process.exitCode = 1; return; }
|
|
418
|
-
const current = S.computeCandidate(root, S.
|
|
430
|
+
const current = S.computeCandidate(root, S.readEffectiveConfig(root));
|
|
419
431
|
const spent = S.budgetSpent(sello, current || { numstat: {} });
|
|
420
432
|
if (spent <= sello.budget.lines) { say(`✅ within budget: ~${spent}/${sello.budget.lines} line(s).`); return; }
|
|
421
433
|
const justify = str('justify');
|
|
@@ -448,12 +460,21 @@ async function main() {
|
|
|
448
460
|
const enabled = S.isEnabled(root);
|
|
449
461
|
const verdict = S.checkSello(root);
|
|
450
462
|
const sello = S.readSello(root);
|
|
451
|
-
const cfg = S.
|
|
463
|
+
const cfg = S.readEffectiveConfig(root);
|
|
452
464
|
let lowered = [];
|
|
453
465
|
let configError;
|
|
454
466
|
try { ({ lowered } = S.validateRiskConfig(cfg || {})); } catch (e) { configError = e.message; }
|
|
467
|
+
const globalCfg = S.readGlobalConfig();
|
|
468
|
+
const projectCfg = S.readConfig(root);
|
|
455
469
|
say(JSON.stringify({
|
|
456
470
|
enabled,
|
|
471
|
+
// Which scope decided, and what each one says — two scopes quietly
|
|
472
|
+
// disagreeing is a failure this harness has already lived through.
|
|
473
|
+
decidedBy: cfg?.scope,
|
|
474
|
+
scopes: {
|
|
475
|
+
global: globalCfg?.enabled === undefined ? 'unset' : globalCfg.enabled ? 'on' : 'off',
|
|
476
|
+
project: projectCfg?.enabled === undefined ? 'unset' : projectCfg.enabled ? 'on' : 'off',
|
|
477
|
+
},
|
|
457
478
|
check: verdict.code,
|
|
458
479
|
// An inert gate must never look armed: name every reason it is standing down.
|
|
459
480
|
inert: enabled && ['no-trunk', 'disabled'].includes(verdict.code) ? verdict.code : undefined,
|
|
@@ -471,7 +492,7 @@ async function main() {
|
|
|
471
492
|
return;
|
|
472
493
|
}
|
|
473
494
|
default:
|
|
474
|
-
say('Use: npx @ericrisco/rsc sello on|off|status|freeze|approve --lenses a,b [--accept-partial-lenses]|block --reason "…"|budget --lines N|budget-check [--justify "…"]|check|report');
|
|
495
|
+
say('Use: npx @ericrisco/rsc sello on|off [--global]|status|freeze|approve --lenses a,b [--accept-partial-lenses]|block --reason "…"|budget --lines N|budget-check [--justify "…"]|check|report');
|
|
475
496
|
return;
|
|
476
497
|
}
|
|
477
498
|
}
|
package/skills/review/SKILL.md
CHANGED
|
@@ -104,8 +104,9 @@ End every review you give with one of three, and nothing mushy in between:
|
|
|
104
104
|
|
|
105
105
|
### The sello — when this project opted in
|
|
106
106
|
|
|
107
|
-
If `.rsc/sello-config.json`
|
|
108
|
-
|
|
107
|
+
If the sello is on — `.rsc/sello-config.json` in the project, or `~/.rsc/sello-config.json` for
|
|
108
|
+
every project (`sello on --global`; the project switch always wins) — the review's verdict is
|
|
109
|
+
**sealed to the exact bytes reviewed**, and the ship gate refuses commit/push/PR on anything else.
|
|
109
110
|
|
|
110
111
|
**What the sello does and does not prove.** It binds bytes, not intent: it guarantees *what ships
|
|
111
112
|
is what was reviewed*, never *the review was good*. You are the one calling `sello approve`, so it
|
package/skills/ship/SKILL.md
CHANGED
|
@@ -72,8 +72,10 @@ the command), and can be disabled per project with `.rsc/.no-ship-guard`. It gua
|
|
|
72
72
|
commit → push step; opening the PR is still this skill's job (and its hard rule). If the guard
|
|
73
73
|
blocks you, do not work around it — run ship.
|
|
74
74
|
|
|
75
|
-
The same guard also enforces the **sello** where
|
|
76
|
-
|
|
75
|
+
The same guard also enforces the **sello** where it was opted into — per project
|
|
76
|
+
(`.rsc/sello-config.json`) or for all of them (`~/.rsc/sello-config.json` via
|
|
77
|
+
`sello on --global`, with the project switch always winning; `rsc sello status` prints which
|
|
78
|
+
scope decided): commit, push and PR are denied unless the change's exact bytes match the
|
|
77
79
|
sealed, approved review — one byte of drift, a moved base, or a missing review on a risk>0 change
|
|
78
80
|
all block, and every denial names its way out (re-run `review`, or `npx @ericrisco/rsc sello off`).
|
|
79
81
|
Risk-0 changes (docs/copy) always pass silently. Off by default; the flow lives in the `review`
|
package/targets/sello.mjs
CHANGED
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
|
|
26
26
|
import { existsSync, lstatSync, readFileSync, readlinkSync, writeFileSync, appendFileSync, mkdirSync } from 'node:fs';
|
|
27
27
|
import { join, dirname } from 'node:path';
|
|
28
|
+
import { homedir } from 'node:os';
|
|
28
29
|
import { createHash } from 'node:crypto';
|
|
29
30
|
import { spawnSync } from 'node:child_process';
|
|
30
31
|
|
|
@@ -47,15 +48,49 @@ export function selloPaths(root) {
|
|
|
47
48
|
};
|
|
48
49
|
}
|
|
49
50
|
|
|
50
|
-
|
|
51
|
+
// The user-scope switch: turn the sello on once for every project instead of
|
|
52
|
+
// per repo. RSC_SELLO_HOME exists so tests can point it somewhere disposable
|
|
53
|
+
// (same escape hatch as hook-once's marker dir).
|
|
54
|
+
export function selloHome() {
|
|
55
|
+
return process.env.RSC_SELLO_HOME || homedir();
|
|
56
|
+
}
|
|
57
|
+
export function globalConfigPath(home = selloHome()) {
|
|
58
|
+
return join(home, '.rsc', 'sello-config.json');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function readJson(path) {
|
|
51
62
|
try {
|
|
52
|
-
const cfg = JSON.parse(readFileSync(
|
|
63
|
+
const cfg = JSON.parse(readFileSync(path, 'utf8'));
|
|
53
64
|
return cfg && typeof cfg === 'object' ? cfg : null;
|
|
54
65
|
} catch { return null; }
|
|
55
66
|
}
|
|
56
67
|
|
|
68
|
+
// Project-scope config only — this is what `sello on/off` writes.
|
|
69
|
+
export function readConfig(root) {
|
|
70
|
+
return readJson(selloPaths(root).config);
|
|
71
|
+
}
|
|
72
|
+
export function readGlobalConfig() {
|
|
73
|
+
return readJson(globalConfigPath());
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// What actually governs a project: global as the base, project keys on top.
|
|
77
|
+
// PRECEDENCE IS EXPLICIT AND SURFACED — this harness has already been bitten by
|
|
78
|
+
// two scopes silently disagreeing (a project opt-out that never reached the
|
|
79
|
+
// user-scope guard), so `scope` says which one decided and `status`/`doctor`
|
|
80
|
+
// print it.
|
|
81
|
+
export function readEffectiveConfig(root) {
|
|
82
|
+
const g = readGlobalConfig();
|
|
83
|
+
const p = readConfig(root);
|
|
84
|
+
if (!g && !p) return null;
|
|
85
|
+
const merged = { ...(g || {}), ...(p || {}) };
|
|
86
|
+
merged.scope = p && p.enabled !== undefined ? 'project'
|
|
87
|
+
: g && g.enabled !== undefined ? 'global'
|
|
88
|
+
: 'none';
|
|
89
|
+
return merged;
|
|
90
|
+
}
|
|
91
|
+
|
|
57
92
|
export function isEnabled(root) {
|
|
58
|
-
const cfg =
|
|
93
|
+
const cfg = readEffectiveConfig(root);
|
|
59
94
|
return !!(cfg && cfg.enabled === true);
|
|
60
95
|
}
|
|
61
96
|
|
|
@@ -351,7 +386,7 @@ export const MESSAGES = {
|
|
|
351
386
|
|
|
352
387
|
export function checkSello(rawRoot) {
|
|
353
388
|
const root = resolveRoot(rawRoot);
|
|
354
|
-
const cfg =
|
|
389
|
+
const cfg = readEffectiveConfig(root);
|
|
355
390
|
if (!cfg || cfg.enabled !== true) return { ok: true, code: 'disabled' };
|
|
356
391
|
|
|
357
392
|
const candidate = computeCandidate(root, cfg);
|