instar 1.3.759 → 1.3.760
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/dist/core/PostUpdateMigrator.d.ts +15 -0
- package/dist/core/PostUpdateMigrator.d.ts.map +1 -1
- package/dist/core/PostUpdateMigrator.js +44 -0
- package/dist/core/PostUpdateMigrator.js.map +1 -1
- package/dist/testing/selfActionRegistry.d.ts +88 -0
- package/dist/testing/selfActionRegistry.d.ts.map +1 -0
- package/dist/testing/selfActionRegistry.js +202 -0
- package/dist/testing/selfActionRegistry.js.map +1 -0
- package/package.json +2 -2
- package/scripts/class-closure-declare.mjs +69 -4
- package/scripts/class-closure-lint.mjs +61 -5
- package/scripts/instar-dev-precommit.js +100 -2
- package/scripts/lib/self-action-detect.mjs +162 -0
- package/scripts/lint-no-unregistered-self-action.js +204 -0
- package/skills/instar-dev/templates/side-effects-artifact.md +8 -1
- package/src/data/builtin-manifest.json +19 -19
- package/upgrades/1.3.760.md +100 -0
- package/upgrades/side-effects/self-action-convergence.md +62 -0
|
@@ -33,6 +33,7 @@ import { verifyProposalDerivedRunbooks } from '../skills/instar-dev/scripts/veri
|
|
|
33
33
|
import { classifyTier, decideRequirementSet } from './lib/classify-tier.mjs';
|
|
34
34
|
import { recognizeConvergence } from './lib/convergence-recognition.mjs';
|
|
35
35
|
import { isOperatorSurfaceFile, artifactAddressesOperatorSurfaceQuality, isAuthorizationSurfaceFile, artifactAddressesAgentProposesApproves, operatorSurfaceRequiresRawInput } from './lib/operator-surface.mjs';
|
|
36
|
+
import { selfActionDeclarationVerdict } from './lib/self-action-detect.mjs';
|
|
36
37
|
|
|
37
38
|
// Report-Backed Converging Audit (docs/specs/CONVERGING-AUDIT-DEFAULT.md, Part B).
|
|
38
39
|
// The precommit reads NO config file and runs pre-compile, so it cannot import
|
|
@@ -180,10 +181,14 @@ if (bootstrapTrigger) {
|
|
|
180
181
|
|
|
181
182
|
let tierSignal = { suggestedTier: 2, sizeTier: 2, riskFloor: 1, reasons: [] };
|
|
182
183
|
let totalChangedLoc = 0;
|
|
184
|
+
// Hoisted to module scope (docs/specs/self-action-convergence.md → E3 impl
|
|
185
|
+
// note): addedDiffText is computed in the Step-3.5 block but consumed later by
|
|
186
|
+
// assertSelfActionDeclared at BOTH the enforceTier1 and Tier-2 pass-through call
|
|
187
|
+
// sites. It must outlive the block.
|
|
188
|
+
let addedDiffText = '';
|
|
183
189
|
{
|
|
184
190
|
let addedLines = 0;
|
|
185
191
|
let deletedLines = 0;
|
|
186
|
-
let addedDiffText = '';
|
|
187
192
|
try {
|
|
188
193
|
const numstat = execSync(
|
|
189
194
|
`git diff --cached --numstat -- ${inScopeFiles.map((f) => JSON.stringify(f)).join(' ')}`,
|
|
@@ -337,6 +342,7 @@ const decisionEntryPath = writeDecisionAudit({
|
|
|
337
342
|
files: inScopeFiles.length,
|
|
338
343
|
loc: totalChangedLoc,
|
|
339
344
|
causalAutopsy,
|
|
345
|
+
classClosure: (freshestTrace && typeof freshestTrace.classClosure === 'object' && freshestTrace.classClosure) || null,
|
|
340
346
|
});
|
|
341
347
|
// Malformed autopsy blocks AFTER the audit write — the blocked attempt is
|
|
342
348
|
// recorded (verdict 'blocked' via the exit handler), same as every gate
|
|
@@ -886,6 +892,7 @@ if (!promotionGateResult.ok) {
|
|
|
886
892
|
|
|
887
893
|
assertFrameworkGenerality(inScopeFiles, validTrace.trace);
|
|
888
894
|
assertOperatorSurfaceQuality(staged, validTrace.trace);
|
|
895
|
+
assertSelfActionDeclared(addedDiffText, inScopeFiles, validTrace.trace);
|
|
889
896
|
|
|
890
897
|
console.error(
|
|
891
898
|
`[instar-dev-precommit] OK — trace ${path.basename(validTrace.entry.file)} covers ${inScopeFiles.length} in-scope file(s), artifact ${validTrace.trace.artifactPath} verified, spec ${spec} is converged + approved` +
|
|
@@ -1035,6 +1042,90 @@ function assertOperatorSurfaceQuality(stagedFiles, trace) {
|
|
|
1035
1042
|
}
|
|
1036
1043
|
}
|
|
1037
1044
|
|
|
1045
|
+
// Self-Action Convergence gate (docs/specs/self-action-convergence.md → E3).
|
|
1046
|
+
// The light (Tier-1) path is where #1035 (swap-thrash) slipped through with no
|
|
1047
|
+
// adversarial reviewer. When an ADDED diff introduces/modifies a self-action
|
|
1048
|
+
// emit (restart|swap|respawn|spawn|notify|retry|re-drive|kill) AND a src/ file
|
|
1049
|
+
// is touched, the change must declare the `unbounded-self-action` class in the
|
|
1050
|
+
// TRACE — either a real classClosure declaration (guard|gap) OR an explicit
|
|
1051
|
+
// negative declaration (closure:'n/a' + reason) — the trace-level analog of the
|
|
1052
|
+
// D4 lint's allowlist. A genuine one-shot user-driven call costs one attested
|
|
1053
|
+
// line, never an unescapable block.
|
|
1054
|
+
//
|
|
1055
|
+
// FAIL-OPEN on tooling failure (the safe asymmetry): empty addedDiffText, no
|
|
1056
|
+
// src/ file, or an unreadable artifact does NOT fire — a false-negative here is
|
|
1057
|
+
// backstopped by the E2 CI lint; a false-positive that blocked all commits
|
|
1058
|
+
// would sever the developer's ability to ship. Called at BOTH pass-through
|
|
1059
|
+
// points (enforceTier1 + the Tier-2 fall-through) — the both-call-sites detail
|
|
1060
|
+
// is load-bearing (the light path is where #1035 escaped).
|
|
1061
|
+
function assertSelfActionDeclared(addedDiffText, inScopeFilesArg, trace) {
|
|
1062
|
+
const verdict = selfActionDeclarationVerdict({
|
|
1063
|
+
addedDiffText,
|
|
1064
|
+
inScopeFiles: inScopeFilesArg,
|
|
1065
|
+
classClosure: trace && trace.classClosure,
|
|
1066
|
+
});
|
|
1067
|
+
if (!verdict.required) return; // fail-open / not a self-action change
|
|
1068
|
+
|
|
1069
|
+
if (!verdict.satisfied) {
|
|
1070
|
+
blockCommit(
|
|
1071
|
+
inScopeFilesArg,
|
|
1072
|
+
[
|
|
1073
|
+
'Self-Action Convergence gate:',
|
|
1074
|
+
' This change ADDS or modifies a self-triggered action in src/ (a',
|
|
1075
|
+
' restart / swap / respawn / spawn / notify / retry / re-drive / kill),',
|
|
1076
|
+
' but the trace carries no `unbounded-self-action` class declaration.',
|
|
1077
|
+
'',
|
|
1078
|
+
' A self-triggered action must be proven to CONVERGE under sustained',
|
|
1079
|
+
' pressure (not just be individually correct). Declare it in your trace:',
|
|
1080
|
+
'',
|
|
1081
|
+
' • If it is a self-triggered controller — register it in',
|
|
1082
|
+
' src/testing/selfActionRegistry.ts (so tests/unit/self-action-convergence.test.ts',
|
|
1083
|
+
' proves it settles) and declare closure:"guard" citing that ratchet:',
|
|
1084
|
+
' node scripts/class-closure-declare.mjs --to-trace \\',
|
|
1085
|
+
' --class unbounded-self-action --closure guard \\',
|
|
1086
|
+
' --citation tests/unit/self-action-convergence.test.ts \\',
|
|
1087
|
+
' --enforcement ratchet --how-caught "<convergence argument: steady-state bound + settling brake>"',
|
|
1088
|
+
'',
|
|
1089
|
+
' • If the guard is out of THIS change\'s scope — closure:"gap" with a',
|
|
1090
|
+
' tracked evolution-action id (--closure gap --gap-item <id>).',
|
|
1091
|
+
'',
|
|
1092
|
+
' • If it is genuinely a ONE-SHOT / user-driven action (not a',
|
|
1093
|
+
' self-triggered loop) — an explicit negative declaration:',
|
|
1094
|
+
' { "defectClass": "unbounded-self-action", "closure": "n/a",',
|
|
1095
|
+
' "reason": "one-shot user-driven action, not a self-triggered loop" }',
|
|
1096
|
+
'',
|
|
1097
|
+
' (Standard: docs/STANDARDS-REGISTRY.md → "Capacity Safety — No Unbounded',
|
|
1098
|
+
' Self-Action". Spec: docs/specs/self-action-convergence.md → Part E3.)',
|
|
1099
|
+
].join('\n'),
|
|
1100
|
+
);
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
// Mirror check (the display-only human mirror — the two hosts #1347 uses).
|
|
1104
|
+
// Fail-OPEN if the artifact is unreadable.
|
|
1105
|
+
const artifactRel = trace && (trace.sideEffectsPath || trace.artifactPath);
|
|
1106
|
+
if (artifactRel) {
|
|
1107
|
+
const abs = path.resolve(ROOT, artifactRel);
|
|
1108
|
+
if (fs.existsSync(abs)) {
|
|
1109
|
+
const content = fs.readFileSync(abs, 'utf8');
|
|
1110
|
+
if (!/unbounded-self-action/.test(content)) {
|
|
1111
|
+
blockCommit(
|
|
1112
|
+
inScopeFilesArg,
|
|
1113
|
+
[
|
|
1114
|
+
'Self-Action Convergence gate (mirror):',
|
|
1115
|
+
' The trace declares the unbounded-self-action class, but the staged',
|
|
1116
|
+
` side-effects artifact (${artifactRel}) never mentions it — the`,
|
|
1117
|
+
' display-only human mirror must AGREE with the machine-readable trace',
|
|
1118
|
+
' declaration (the two hosts #1347 uses).',
|
|
1119
|
+
'',
|
|
1120
|
+
' Add the class-closure declaration to the side-effects artifact\'s',
|
|
1121
|
+
' "## Class-Closure Declaration" section.',
|
|
1122
|
+
].join('\n'),
|
|
1123
|
+
);
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1038
1129
|
function blockCommit(files, reason) {
|
|
1039
1130
|
console.error('');
|
|
1040
1131
|
console.error('╔════════════════════════════════════════════════════════════════════╗');
|
|
@@ -1065,7 +1156,7 @@ function blockCommit(files, reason) {
|
|
|
1065
1156
|
// fire, the line just evaporated with the worktree). If the commit is later
|
|
1066
1157
|
// blocked by the gate, the staged line simply rides the retry commit — both
|
|
1067
1158
|
// lines describe real gate evaluations.
|
|
1068
|
-
function writeDecisionAudit({ slug, suggestedTier, declaredTier, riskFloor, riskFloorReasons, belowFloor, files, loc, causalAutopsy = null }) {
|
|
1159
|
+
function writeDecisionAudit({ slug, suggestedTier, declaredTier, riskFloor, riskFloorReasons, belowFloor, files, loc, causalAutopsy = null, classClosure = null }) {
|
|
1069
1160
|
try {
|
|
1070
1161
|
fs.mkdirSync(DECISIONS_DIR, { recursive: true });
|
|
1071
1162
|
const ts = new Date().toISOString();
|
|
@@ -1097,6 +1188,12 @@ function writeDecisionAudit({ slug, suggestedTier, declaredTier, riskFloor, risk
|
|
|
1097
1188
|
// This is THE meta-analysis substrate: convergence vs whack-a-mole is
|
|
1098
1189
|
// a query over these entries, not archaeology.
|
|
1099
1190
|
causalAutopsy,
|
|
1191
|
+
// Class-closure declaration (docs/specs/class-closure-gate.md +
|
|
1192
|
+
// self-action-convergence.md → E3): persisted from the instar-dev TRACE
|
|
1193
|
+
// into the machine-readable decision-audit host the CI class-closure lint
|
|
1194
|
+
// reads — closing the chicken-and-egg (the trace is authored before the
|
|
1195
|
+
// commit; the entry is written by this hook). null = not declared.
|
|
1196
|
+
classClosure,
|
|
1100
1197
|
// Finalized by the process exit handler below: 'pass' when the gate
|
|
1101
1198
|
// allowed the commit, 'blocked' otherwise. The riding-the-retry design
|
|
1102
1199
|
// (a blocked evaluation's entry rides the next successful commit, see
|
|
@@ -1231,6 +1328,7 @@ function enforceTier1(trace, traceFile) {
|
|
|
1231
1328
|
|
|
1232
1329
|
assertFrameworkGenerality(inScopeFiles, trace);
|
|
1233
1330
|
assertOperatorSurfaceQuality(staged, trace);
|
|
1331
|
+
assertSelfActionDeclared(addedDiffText, inScopeFiles, trace);
|
|
1234
1332
|
|
|
1235
1333
|
console.error(
|
|
1236
1334
|
`[instar-dev-precommit] OK (Tier 1) — trace ${traceName} covers ${inScopeFiles.length} in-scope file(s), ` +
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
// self-action-detect.mjs — the SINGLE shared detector for the
|
|
2
|
+
// `unbounded-self-action` defect class (docs/specs/self-action-convergence.md
|
|
3
|
+
// → Part E1). Dependency-free ESM, mirroring how the class-closure gate shares
|
|
4
|
+
// `class-closure-grader.mjs` as one library used by both the CI lint and the
|
|
5
|
+
// commit gate.
|
|
6
|
+
//
|
|
7
|
+
// It answers three questions, all deterministically (Signal vs. Authority — it
|
|
8
|
+
// FORCES a declaration, it never LLM-guesses):
|
|
9
|
+
// 1. Does an ADDED diff introduce/modify a self-action EMIT? (the trigger for
|
|
10
|
+
// the class declaration requirement — emit-anchored, comment/prose-safe).
|
|
11
|
+
// 2. Is a file a self-action CONTROLLER source file? (the scope predicate the
|
|
12
|
+
// CI lint + forcing lint use).
|
|
13
|
+
// 3. What is the raw verb-token set? (the single source the D3 ratchet's
|
|
14
|
+
// registry `actionVerb`s must be a subset of — coherence test E5/D5).
|
|
15
|
+
//
|
|
16
|
+
// SAFETY ASYMMETRY (fail-OPEN on tooling failure — the E3/E2 gates rely on it):
|
|
17
|
+
// empty/blank added-diff text returns FALSE (no fire). A false-negative here is
|
|
18
|
+
// backstopped by the CI lint; a false-positive that blocked all commits would
|
|
19
|
+
// sever the developer's ability to ship. The obfuscation limit (a string-based
|
|
20
|
+
// detector cannot beat DELIBERATE evasion — `const v='swap'; self[v]()`) is the
|
|
21
|
+
// known gap named in the spec, tracked to the follow-on funnel (Part B).
|
|
22
|
+
|
|
23
|
+
// ── The self-action verb TOKENS ────────────────────────────────────────────
|
|
24
|
+
// Seeded from the synthesis taxonomy (restart|swap|respawn|spawn|notify|retry|
|
|
25
|
+
// re-drive|kill) and widened to the concrete instar symbols those verbs appear
|
|
26
|
+
// as. This is the SINGLE source: the emit regex is built from it, and the D3
|
|
27
|
+
// ratchet's registry `actionVerb`s are asserted to each contain one of these
|
|
28
|
+
// tokens (so a new controller can never register a verb the detector is blind
|
|
29
|
+
// to). Keep alphabetized-by-family for review.
|
|
30
|
+
export const SELF_ACTION_VERB_TOKENS = Object.freeze([
|
|
31
|
+
// restart / respawn family
|
|
32
|
+
'refresh', 'respawn', 'restart',
|
|
33
|
+
// reap / kill family
|
|
34
|
+
'reap', 'requestKill', 'kill',
|
|
35
|
+
// swap family
|
|
36
|
+
'swap', 'proactiveSwap',
|
|
37
|
+
// spawn family
|
|
38
|
+
'spawnSession', 'spawn',
|
|
39
|
+
// notify / emit family
|
|
40
|
+
'notify', 'createForumTopic', 'createAttentionItem', 'sendToTopic',
|
|
41
|
+
// re-drive / re-pin family
|
|
42
|
+
'reDrive', 'redrive', 'rePin', 'repin',
|
|
43
|
+
// retry / nudge / escalate family
|
|
44
|
+
'retry', 'nudge', 'escalate',
|
|
45
|
+
]);
|
|
46
|
+
|
|
47
|
+
// A call in an EMITTING position: `verb(` (a bare/qualified call) OR `.verb(`
|
|
48
|
+
// (a method call). A bare NOUN in a comment or prose line does not match
|
|
49
|
+
// because the line-level filter in addedDiffIntroducesSelfAction strips
|
|
50
|
+
// comment/prose lines before testing; the regex itself additionally requires a
|
|
51
|
+
// `(` so `// swap accounts` (no paren) never matches even if the filter is
|
|
52
|
+
// bypassed.
|
|
53
|
+
const CALL_VERBS = SELF_ACTION_VERB_TOKENS.join('|');
|
|
54
|
+
// Method-position verbs — the subset that commonly appears as `obj.verb(`.
|
|
55
|
+
const METHOD_VERBS = [
|
|
56
|
+
'refresh', 'respawn', 'restart', 'swap', 'reap', 'kill', 'retry',
|
|
57
|
+
'escalate', 'nudge', 'notify', 'spawn', 'redrive',
|
|
58
|
+
].join('|');
|
|
59
|
+
|
|
60
|
+
export const SELF_ACTION_EMIT = new RegExp(
|
|
61
|
+
`\\b(${CALL_VERBS})\\s*\\(|\\.\\s*(${METHOD_VERBS})\\s*\\(`,
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
// ── Comment / prose line detection ─────────────────────────────────────────
|
|
65
|
+
// The added-diff text handed to us is already `+`-stripped by the precommit
|
|
66
|
+
// hook (or a raw file line, for the forcing lint). We skip lines that are
|
|
67
|
+
// obviously NOT an emitting statement — a comment, a markdown/prose line, an
|
|
68
|
+
// import, or a string-literal-only mention — before testing the emit regex.
|
|
69
|
+
function isNonEmitLine(line) {
|
|
70
|
+
const t = line.trimStart();
|
|
71
|
+
if (t === '') return true;
|
|
72
|
+
// Line comments / block-comment continuations / hash comments.
|
|
73
|
+
if (/^(\/\/|\*|\/\*|#)/.test(t)) return true;
|
|
74
|
+
// Import / export-from lines mention symbols but do not call them.
|
|
75
|
+
if (/^(import|export)\b/.test(t) && /\bfrom\b/.test(t)) return true;
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Does the ADDED diff text introduce/modify a self-action emit? Emitting
|
|
81
|
+
* position only — a bare noun in a comment/prose line does NOT match. Empty /
|
|
82
|
+
* blank text → false (fail-open on a tooling hiccup).
|
|
83
|
+
* @param {string} addedDiffText concatenation of added lines (`+`-stripped)
|
|
84
|
+
* @returns {boolean}
|
|
85
|
+
*/
|
|
86
|
+
export function addedDiffIntroducesSelfAction(addedDiffText) {
|
|
87
|
+
if (typeof addedDiffText !== 'string' || addedDiffText.trim() === '') return false;
|
|
88
|
+
for (const line of addedDiffText.split('\n')) {
|
|
89
|
+
if (isNonEmitLine(line)) continue;
|
|
90
|
+
if (SELF_ACTION_EMIT.test(line)) return true;
|
|
91
|
+
}
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ── Controller-source scope predicate ──────────────────────────────────────
|
|
96
|
+
// A `src/` file whose NAME matches the controller shape, OR any file that
|
|
97
|
+
// carries the `@self-action-controller` registration marker in its content.
|
|
98
|
+
const CONTROLLER_NAME =
|
|
99
|
+
/(Monitor|Sentinel|Reaper|Beacon|Engine|Scheduler|Watchdog|Poller|Manager)\.ts$/;
|
|
100
|
+
const CONTROLLER_MARKER = /@self-action-controller\s*:/;
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Is `file` a self-action controller source file (for scope)?
|
|
104
|
+
* @param {string} file repo-relative path
|
|
105
|
+
* @param {string} [contentMaybe] optional file content, to catch the marker
|
|
106
|
+
* @returns {boolean}
|
|
107
|
+
*/
|
|
108
|
+
export function isSelfActionControllerFile(file, contentMaybe) {
|
|
109
|
+
const f = String(file ?? '');
|
|
110
|
+
if (typeof contentMaybe === 'string' && CONTROLLER_MARKER.test(contentMaybe)) return true;
|
|
111
|
+
if (!f.startsWith('src/')) return false;
|
|
112
|
+
if (f.endsWith('.test.ts')) return false;
|
|
113
|
+
const base = f.split('/').pop() ?? f;
|
|
114
|
+
return CONTROLLER_NAME.test(base);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Parse the `@self-action-controller: <id>` marker id out of file content, if
|
|
119
|
+
* present. Returns the id string, or null. (Used by the forcing lint to
|
|
120
|
+
* cross-check the marker id against the SELF_ACTION_CONTROLLERS registry.)
|
|
121
|
+
* @param {string} content
|
|
122
|
+
* @returns {string|null}
|
|
123
|
+
*/
|
|
124
|
+
export function selfActionControllerMarkerId(content) {
|
|
125
|
+
if (typeof content !== 'string') return null;
|
|
126
|
+
const m = content.match(/@self-action-controller\s*:\s*([A-Za-z0-9_-]+)/);
|
|
127
|
+
return m ? m[1] : null;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export const SELF_ACTION_CLASS_ID = 'unbounded-self-action';
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* The PURE decision behind the instar-dev pre-commit gate `assertSelfActionDeclared`
|
|
134
|
+
* (Part E3). Extracted here so the decision is a tested function (Structure >
|
|
135
|
+
* Willpower) that BOTH the enforceTier1 and Tier-2 call sites exercise via one
|
|
136
|
+
* gate. FAIL-OPEN: no self-action emit, or no src/ file touched -> not required.
|
|
137
|
+
* A declaration is satisfied by a real classClosure (guard|gap) OR an explicit
|
|
138
|
+
* negative declaration (closure:'n/a' + reason).
|
|
139
|
+
* @param {{ addedDiffText: string, inScopeFiles: string[], classClosure: any }} input
|
|
140
|
+
* @returns {{ required: boolean, satisfied: boolean, reason: string }}
|
|
141
|
+
*/
|
|
142
|
+
export function selfActionDeclarationVerdict({ addedDiffText, inScopeFiles, classClosure }) {
|
|
143
|
+
if (!addedDiffIntroducesSelfAction(addedDiffText)) {
|
|
144
|
+
return { required: false, satisfied: true, reason: 'no self-action emit in added diff (fail-open)' };
|
|
145
|
+
}
|
|
146
|
+
const srcTouched = (inScopeFiles || []).some((f) => String(f).startsWith('src/'));
|
|
147
|
+
if (!srcTouched) {
|
|
148
|
+
return { required: false, satisfied: true, reason: 'no src/ file touched (false-positive-safe)' };
|
|
149
|
+
}
|
|
150
|
+
const cc = classClosure;
|
|
151
|
+
const named = cc && typeof cc === 'object' && cc.defectClass === SELF_ACTION_CLASS_ID;
|
|
152
|
+
const isRealDecl = Boolean(named && (cc.closure === 'guard' || cc.closure === 'gap'));
|
|
153
|
+
const isNegative = Boolean(
|
|
154
|
+
named && cc.closure === 'n/a' && typeof cc.reason === 'string' && cc.reason.trim().length > 0,
|
|
155
|
+
);
|
|
156
|
+
const satisfied = isRealDecl || isNegative;
|
|
157
|
+
return {
|
|
158
|
+
required: true,
|
|
159
|
+
satisfied,
|
|
160
|
+
reason: satisfied ? 'unbounded-self-action declared' : 'missing unbounded-self-action declaration',
|
|
161
|
+
};
|
|
162
|
+
}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// safe-git-allow: CI-only lint — single read-only `git diff --cached --name-only`
|
|
3
|
+
// to list staged files; runs on runners where the TS SafeGitExecutor is not
|
|
4
|
+
// importable from a standalone .js.
|
|
5
|
+
/**
|
|
6
|
+
* lint-no-unregistered-self-action.js — the FORCING lint for the
|
|
7
|
+
* `unbounded-self-action` defect class (docs/specs/self-action-convergence.md
|
|
8
|
+
* → Part D4). Structural twin of `lint-no-unbounded-llm-spawn.js`, and — per
|
|
9
|
+
* both external reviewers (codex + gemini) — emit-anchored and fail-closed, NOT
|
|
10
|
+
* shape-heuristic: it keys on the self-action EMIT itself (the shared detector,
|
|
11
|
+
* scripts/lib/self-action-detect.mjs), so a developer cannot dodge it by
|
|
12
|
+
* renaming the driver method.
|
|
13
|
+
*
|
|
14
|
+
* THE RULE (fail-closed once enforcing): a self-action CONTROLLER source file
|
|
15
|
+
* (the E1 scope predicate — a src/ *Monitor|*Sentinel|*Reaper|*Beacon|*Engine|
|
|
16
|
+
* *Scheduler|*Watchdog|*Poller|*Manager.ts, or any file carrying the marker)
|
|
17
|
+
* that contains a self-action emit MUST EITHER:
|
|
18
|
+
* - register — carry `@self-action-controller: <id>` AND have <id> present in
|
|
19
|
+
* SELF_ACTION_CONTROLLERS (so the convergence ratchet covers it), OR
|
|
20
|
+
* - be allowlisted — appear in ALLOWLIST with a stated reason that it is a
|
|
21
|
+
* genuine one-shot / user-driven action, not a self-triggered loop.
|
|
22
|
+
* Anything else is a violation.
|
|
23
|
+
*
|
|
24
|
+
* ROLLOUT (matches the class-closure gate it composes with): REPORT-ONLY by
|
|
25
|
+
* default — it prints the population of unregistered controllers and ALWAYS
|
|
26
|
+
* exits 0. It exits nonzero ONLY when `prGate.classClosure.enabled && !dryRun`
|
|
27
|
+
* (the enforcing flip the operator makes on measured population). This is
|
|
28
|
+
* "enforcement first, report-only, graduate after a clean soak."
|
|
29
|
+
*
|
|
30
|
+
* SCOPE (report-only): controller-SHAPE files only (the E1 predicate) — the
|
|
31
|
+
* exact files the class is about — so the telemetry is actionable rather than a
|
|
32
|
+
* repo-wide wall of every `retry(` in src/. Widening to ALL src/ emits is gated
|
|
33
|
+
* behind the enforcing flip + the obfuscation-resistant funnel (Part B).
|
|
34
|
+
* <!-- tracked: CMT-1911 -->
|
|
35
|
+
*
|
|
36
|
+
* HONEST COVERAGE LIMIT (both reviewers, recorded): a string-based lint deters
|
|
37
|
+
* ACCIDENTAL unbounded loops; it is not a hard boundary against DELIBERATE
|
|
38
|
+
* obfuscation (`const v='swap'; self[v]()`). The obfuscation-resistant closure
|
|
39
|
+
* is the funnel (Part B); deliberately obfuscating an emit to evade this gate is
|
|
40
|
+
* a conduct violation, not a clever workaround.
|
|
41
|
+
*
|
|
42
|
+
* Usage:
|
|
43
|
+
* node scripts/lint-no-unregistered-self-action.js # full repo
|
|
44
|
+
* node scripts/lint-no-unregistered-self-action.js --staged # staged files
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
import fs from 'node:fs';
|
|
48
|
+
import path from 'node:path';
|
|
49
|
+
import { execSync } from 'node:child_process';
|
|
50
|
+
import { fileURLToPath } from 'node:url';
|
|
51
|
+
|
|
52
|
+
import {
|
|
53
|
+
SELF_ACTION_EMIT,
|
|
54
|
+
isSelfActionControllerFile,
|
|
55
|
+
selfActionControllerMarkerId,
|
|
56
|
+
} from './lib/self-action-detect.mjs';
|
|
57
|
+
|
|
58
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
59
|
+
const ROOT = path.resolve(path.dirname(__filename), '..');
|
|
60
|
+
|
|
61
|
+
const SCAN_DIRS = ['src'];
|
|
62
|
+
const EXTENSIONS = new Set(['.ts']);
|
|
63
|
+
|
|
64
|
+
// ── Allowlist (closed). Adding an entry requires a stated reason that the
|
|
65
|
+
// file's self-action emit is a genuine one-shot / user-driven action or is
|
|
66
|
+
// otherwise bounded outside the registry — same governance as the spawn
|
|
67
|
+
// lint's allowlist. ─────────────────────────────────────────────────────
|
|
68
|
+
const ALLOWLIST = new Set([
|
|
69
|
+
// The controller-registration harness itself names verbs + the marker.
|
|
70
|
+
'src/testing/selfActionRegistry.ts',
|
|
71
|
+
]);
|
|
72
|
+
|
|
73
|
+
/** Read the prGate.classClosure config, or the report-only default. */
|
|
74
|
+
export function loadEnforcementConfig(repoRoot) {
|
|
75
|
+
try {
|
|
76
|
+
const cfg = JSON.parse(fs.readFileSync(path.join(repoRoot, '.instar', 'config.json'), 'utf-8'));
|
|
77
|
+
const cc = cfg && cfg.prGate && cfg.prGate.classClosure;
|
|
78
|
+
if (cc && typeof cc === 'object') {
|
|
79
|
+
return { enabled: cc.enabled === true, dryRun: cc.dryRun !== false };
|
|
80
|
+
}
|
|
81
|
+
} catch {
|
|
82
|
+
/* no config → report-only default */
|
|
83
|
+
}
|
|
84
|
+
return { enabled: false, dryRun: true };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Parse the registered controller ids out of the registry source (greppable `id: '...'`). */
|
|
88
|
+
export function loadRegistryIds(registrySource) {
|
|
89
|
+
const ids = new Set();
|
|
90
|
+
if (typeof registrySource !== 'string') return ids;
|
|
91
|
+
const re = /\bid:\s*['"`]([A-Za-z0-9_-]+)['"`]/g;
|
|
92
|
+
let m;
|
|
93
|
+
while ((m = re.exec(registrySource)) !== null) ids.add(m[1]);
|
|
94
|
+
return ids;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** A line is an emitting statement if it matches the detector AND is not a comment/prose line. */
|
|
98
|
+
function lineIsEmit(line) {
|
|
99
|
+
const t = line.trimStart();
|
|
100
|
+
if (t === '' || /^(\/\/|\*|\/\*|#)/.test(t)) return false;
|
|
101
|
+
if (/^(import|export)\b/.test(t) && /\bfrom\b/.test(t)) return false;
|
|
102
|
+
return SELF_ACTION_EMIT.test(line);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Pure evaluation over a prepared file set — exported for tests.
|
|
107
|
+
* @param {{ files: string[], registryIds: Set<string>, allowlist?: Set<string>, readFile: (rel:string)=>string|null }} input
|
|
108
|
+
* @returns {{ violations: Array<{file:string, reason:string, line:number}>, considered: number }}
|
|
109
|
+
*/
|
|
110
|
+
export function evaluateSelfActionLint({ files, registryIds, allowlist = ALLOWLIST, readFile }) {
|
|
111
|
+
const violations = [];
|
|
112
|
+
let considered = 0;
|
|
113
|
+
for (const rel of files) {
|
|
114
|
+
const norm = rel.split(path.sep).join('/');
|
|
115
|
+
if (!EXTENSIONS.has(path.extname(norm))) continue;
|
|
116
|
+
if (/(^|\/)tests\//.test(norm) || /\.test\.ts$/.test(norm)) continue;
|
|
117
|
+
if (allowlist.has(norm)) continue;
|
|
118
|
+
const content = readFile(norm);
|
|
119
|
+
if (content == null) continue;
|
|
120
|
+
// Scope: a controller-shape file OR a file carrying the marker.
|
|
121
|
+
if (!isSelfActionControllerFile(norm, content)) continue;
|
|
122
|
+
// Does it emit a self-action?
|
|
123
|
+
const lines = content.split('\n');
|
|
124
|
+
let emitLine = -1;
|
|
125
|
+
for (let i = 0; i < lines.length; i++) {
|
|
126
|
+
if (lineIsEmit(lines[i])) { emitLine = i + 1; break; }
|
|
127
|
+
}
|
|
128
|
+
if (emitLine === -1) continue; // a controller-shape file with no emit is fine
|
|
129
|
+
considered += 1;
|
|
130
|
+
// Registered? — carries the marker AND its id is in the registry.
|
|
131
|
+
const markerId = selfActionControllerMarkerId(content);
|
|
132
|
+
if (markerId && registryIds.has(markerId)) continue; // registered — the ratchet covers it
|
|
133
|
+
if (markerId && !registryIds.has(markerId)) {
|
|
134
|
+
violations.push({
|
|
135
|
+
file: norm,
|
|
136
|
+
line: emitLine,
|
|
137
|
+
reason: `carries @self-action-controller: ${markerId} but that id is NOT in SELF_ACTION_CONTROLLERS (register it in src/testing/selfActionRegistry.ts)`,
|
|
138
|
+
});
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
violations.push({
|
|
142
|
+
file: norm,
|
|
143
|
+
line: emitLine,
|
|
144
|
+
reason: 'a self-action controller with an unregistered emit — add /* @self-action-controller: <id> */ and register <id> in SELF_ACTION_CONTROLLERS (or allowlist it with a one-shot/user-driven reason)',
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
return { violations, considered };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ── CLI: gather files, evaluate, report/enforce ────────────────────────────
|
|
151
|
+
function listFiles() {
|
|
152
|
+
const staged = process.argv.includes('--staged');
|
|
153
|
+
if (staged) {
|
|
154
|
+
const out = execSync('git diff --cached --name-only', { cwd: ROOT, encoding: 'utf-8' });
|
|
155
|
+
return out.split('\n').filter(Boolean).filter((f) => EXTENSIONS.has(path.extname(f)));
|
|
156
|
+
}
|
|
157
|
+
const files = [];
|
|
158
|
+
const walk = (dir) => {
|
|
159
|
+
let entries;
|
|
160
|
+
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
161
|
+
for (const e of entries) {
|
|
162
|
+
if (e.name === 'node_modules' || e.name === '.git' || e.name === 'dist') continue;
|
|
163
|
+
const full = path.join(dir, e.name);
|
|
164
|
+
if (e.isDirectory()) walk(full);
|
|
165
|
+
else if (EXTENSIONS.has(path.extname(e.name))) files.push(path.relative(ROOT, full));
|
|
166
|
+
}
|
|
167
|
+
};
|
|
168
|
+
for (const d of SCAN_DIRS) walk(path.join(ROOT, d));
|
|
169
|
+
return files;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const invokedDirectly = process.argv[1] && import.meta.url.endsWith(process.argv[1].split('/').pop());
|
|
173
|
+
if (invokedDirectly) {
|
|
174
|
+
const registrySource = (() => {
|
|
175
|
+
try { return fs.readFileSync(path.join(ROOT, 'src', 'testing', 'selfActionRegistry.ts'), 'utf-8'); }
|
|
176
|
+
catch { return ''; }
|
|
177
|
+
})();
|
|
178
|
+
const registryIds = loadRegistryIds(registrySource);
|
|
179
|
+
const files = listFiles();
|
|
180
|
+
const { violations, considered } = evaluateSelfActionLint({
|
|
181
|
+
files,
|
|
182
|
+
registryIds,
|
|
183
|
+
readFile: (rel) => {
|
|
184
|
+
try { return fs.readFileSync(path.join(ROOT, rel), 'utf-8'); } catch { return null; }
|
|
185
|
+
},
|
|
186
|
+
});
|
|
187
|
+
const { enabled, dryRun } = loadEnforcementConfig(ROOT);
|
|
188
|
+
const enforcing = enabled && !dryRun;
|
|
189
|
+
|
|
190
|
+
console.log(`lint-no-unregistered-self-action: ${enforcing ? 'ENFORCING' : 'report-only'} — ` +
|
|
191
|
+
`${considered} controller-shape emitter(s) scanned, ${violations.length} unregistered.`);
|
|
192
|
+
for (const v of violations) {
|
|
193
|
+
console[enforcing ? 'error' : 'log'](` ${v.file}:${v.line} — ${v.reason}`);
|
|
194
|
+
}
|
|
195
|
+
if (enforcing && violations.length > 0) {
|
|
196
|
+
console.error(`\nlint-no-unregistered-self-action: ${violations.length} violation(s). ` +
|
|
197
|
+
`See docs/specs/self-action-convergence.md (Part D4).`);
|
|
198
|
+
process.exit(1);
|
|
199
|
+
}
|
|
200
|
+
if (violations.length > 0) {
|
|
201
|
+
console.log(' (report-only — no build failure; the enforcing flip is gated on prGate.classClosure.dryRun:false after a measured clean soak.)');
|
|
202
|
+
}
|
|
203
|
+
process.exit(0);
|
|
204
|
+
}
|
|
@@ -183,7 +183,14 @@ pool-wide question is answered by a proxied-on-read merged view." Not abstractio
|
|
|
183
183
|
|
|
184
184
|
**REQUIRED whenever this change FIXES a defect in an agent-authored artifact** (an
|
|
185
185
|
LLM prompt, hook, config, skill, or standards text — see
|
|
186
|
-
`docs/specs/class-closure-gate.md`)
|
|
186
|
+
`docs/specs/class-closure-gate.md`) **— OR adds/modifies a self-triggered
|
|
187
|
+
controller (the `unbounded-self-action` class: a loop, monitor, sentinel,
|
|
188
|
+
reaper, scheduler, or recovery path that fires a restart / swap / respawn /
|
|
189
|
+
spawn / notify / retry / re-drive / kill on its own — see
|
|
190
|
+
`docs/specs/self-action-convergence.md`).** For the self-action case, author the
|
|
191
|
+
convergence argument (control-loop edge + steady-state bound + settling brake)
|
|
192
|
+
INTO `guardEvidence.howCaught`, and cite the ratchet
|
|
193
|
+
`tests/unit/self-action-convergence.test.ts`. This section is the human-readable MIRROR of
|
|
187
194
|
the machine-readable `classClosure` block in the commit's decision-audit entry
|
|
188
195
|
(the host the CI lint validates). **Display-only:** the lint counts the
|
|
189
196
|
decision-audit host ONLY and NEVER sums this mirror — the two are asserted to
|