@starci/eslint-canon-fe 8.0.9 → 8.0.10
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/LICENSE +21 -0
- package/comments.mjs +3 -3
- package/package.json +3 -2
- package/runtime/engine/config.mjs +36 -44
- package/runtime/engine/secrets.mjs +130 -0
- package/runtime/knowledge/hfs/canon-pins.yaml +10 -10
- package/runtime/modules/models/registry.yaml +1 -39
- package/runtime/modules/models/runtimes.yaml +0 -6
- package/runtime/scripts/api/git/lib.mjs +2 -0
- package/runtime/scripts/hfs/architecture/fe-slot-allows.mjs +1 -1
- package/runtime/scripts/lib/jsdoc.mjs +35 -0
- package/runtime/scripts/lib/mutation-fence.mjs +15 -0
- package/runtime/scripts/lib/path-key.mjs +4 -4
- package/runtime/scripts/lib/sops-envelope.mjs +54 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) StarCi contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/comments.mjs
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
18
|
import { hasSecondLanguage } from "./runtime/scripts/lib/language.mjs"
|
|
19
|
+
import { jsdocBefore } from "./runtime/scripts/lib/jsdoc.mjs"
|
|
19
20
|
import { fileOf, inSlot } from "./lib/scope.mjs"
|
|
20
21
|
|
|
21
22
|
/**
|
|
@@ -82,13 +83,12 @@ export const requireExportJsdoc = {
|
|
|
82
83
|
schema: [],
|
|
83
84
|
messages: {
|
|
84
85
|
jsdoc:
|
|
85
|
-
"`{{name}}` is exported
|
|
86
|
+
"`{{name}}` is exported without an adjacent JSDoc description. An export is read far more often than it is written, and by people who never open the body - name the ROLE it plays, not the signature, which the signature already states.",
|
|
86
87
|
},
|
|
87
88
|
},
|
|
88
89
|
create(context) {
|
|
89
90
|
const source = context.sourceCode || context.getSourceCode()
|
|
90
|
-
const hasBlock = (node) =>
|
|
91
|
-
source.getCommentsBefore(node).some((comment) => comment.type === "Block" && comment.value.startsWith("*"))
|
|
91
|
+
const hasBlock = (node) => jsdocBefore(source, node) !== null
|
|
92
92
|
const check = (node) => {
|
|
93
93
|
const declaration = node.declaration
|
|
94
94
|
if (!declaration) return
|
package/package.json
CHANGED
|
@@ -1,15 +1,16 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@starci/eslint-canon-fe",
|
|
3
|
-
"version": "8.0.
|
|
3
|
+
"version": "8.0.10",
|
|
4
4
|
"description": "The StarCi front-end lint canon: every rule its laws publish, gathered as one flat-config plugin and attached by one factory.",
|
|
5
5
|
"type": "module",
|
|
6
|
-
"license": "
|
|
6
|
+
"license": "MIT",
|
|
7
7
|
"private": false,
|
|
8
8
|
"exports": {
|
|
9
9
|
".": "./index.mjs"
|
|
10
10
|
},
|
|
11
11
|
"main": "./index.mjs",
|
|
12
12
|
"files": [
|
|
13
|
+
"LICENSE",
|
|
13
14
|
"*.mjs",
|
|
14
15
|
"lib/*.mjs",
|
|
15
16
|
"runtime/**",
|
|
@@ -6,6 +6,8 @@ import {parseYaml} from './yaml.mjs';
|
|
|
6
6
|
import {isPlainObject as plain} from './plain-object.mjs';
|
|
7
7
|
import {invalid,validateRoots} from './invalid-config.mjs';
|
|
8
8
|
import {validateOrca} from './orca-config.mjs';
|
|
9
|
+
import {ENV_NAME,secretEnv,connectorSecret} from './secrets.mjs';
|
|
10
|
+
export {readDotenv,connectorSecret} from './secrets.mjs';
|
|
9
11
|
|
|
10
12
|
export const configRoot=skillRoot;
|
|
11
13
|
export const NON_OPERATION_ROLES={planner:'plan',kernelManager:'decide',validator:'verify'};
|
|
@@ -40,6 +42,29 @@ export function runtimeProfile(){
|
|
|
40
42
|
* here; a literal copy of any of them in a source file would be a second authority.
|
|
41
43
|
*/
|
|
42
44
|
export function allocationSettings(){return runtimeProfile()?.allocation??{};}
|
|
45
|
+
// These are current owner declarations, never a historical runtime owner's consent.
|
|
46
|
+
function validateOwnerProfile(value,name,pathsKey){
|
|
47
|
+
if(value===null)return;
|
|
48
|
+
const bad=invalid(name);
|
|
49
|
+
if(!plain(value))bad(' must be an owner approval mapping or null.');
|
|
50
|
+
const keys=['approvedBy','approvalRef',pathsKey,...(name==='launchTrust'?['profile']:[])];
|
|
51
|
+
for(const key of Object.keys(value))if(!keys.includes(key))bad(` has unknown key ${key}.`);
|
|
52
|
+
if(value.approvedBy!=='owner')bad('.approvedBy must be owner; adoption must come from the current owner.');
|
|
53
|
+
if(typeof value.approvalRef!=='string'||!value.approvalRef.trim())bad('.approvalRef must identify the current owner approval.');
|
|
54
|
+
if(name==='launchTrust'&&!['automatic','declined'].includes(value.profile))bad('.profile must be automatic or declined.');
|
|
55
|
+
const paths=value[pathsKey];
|
|
56
|
+
if(!Array.isArray(paths)||!paths.length||paths.some(p=>typeof p!=='string'||!(path.isAbsolute(p)||path.win32.isAbsolute(p))||/[\r\n\0]/.test(p)))bad(`.${pathsKey} must list exact absolute repository roots.`);
|
|
57
|
+
}
|
|
58
|
+
export function launchTrustSettings(config=loadConfig()){
|
|
59
|
+
const value=config?.launchTrust??null;validateOwnerProfile(value,'launchTrust','roots');return value;
|
|
60
|
+
}
|
|
61
|
+
export function workflowPurgeSettings(config=loadConfig()){
|
|
62
|
+
const retention=config?.retention??null;
|
|
63
|
+
if(retention===null)return null;
|
|
64
|
+
const bad=invalid('retention');
|
|
65
|
+
if(!plain(retention)||Object.keys(retention).some(k=>k!=='workflowPurge'))bad(' must be {workflowPurge?} or null.');
|
|
66
|
+
const value=retention.workflowPurge??null;validateOwnerProfile(value,'retention.workflowPurge','repos');return value;
|
|
67
|
+
}
|
|
43
68
|
/** One positive millisecond value out of `allocation`, by dotted key. Refuses when the contract omits it. */
|
|
44
69
|
export function allocationMs(dotted){
|
|
45
70
|
const raw=dotted.split('.').reduce((node,key)=>(node==null?node:node[key]),allocationSettings());
|
|
@@ -75,13 +100,11 @@ const CLOUDFLARE_MODES=['off','quick','named'];
|
|
|
75
100
|
/** The serve-ask port band, both ends included (scripts/kernel/ask-server.mjs scans [first..last]); the gateway stays outside it. */
|
|
76
101
|
export const ASK_PORT_BAND=[6969,7069];
|
|
77
102
|
export const CONNECTOR_DEFAULTS=Object.freeze({
|
|
78
|
-
secretsFile:null,
|
|
79
103
|
repos:[],
|
|
80
104
|
gateway:{port:7070},
|
|
81
105
|
cloudflare:{mode:'off',tunnel:null,credentialsFile:null,tokenEnv:'CLOUDFLARE_TUNNEL_TOKEN',hostname:null,access:false},
|
|
82
106
|
telegram:{enabled:false,botTokenEnv:'TELEGRAM_BOT_TOKEN',chatId:null,exposeCredentialAsks:false},
|
|
83
107
|
});
|
|
84
|
-
const ENV_NAME=/^[A-Z_][A-Z0-9_]{0,63}$/;
|
|
85
108
|
const HOSTNAME=/^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/i;
|
|
86
109
|
const TUNNEL_REF=/^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$/;
|
|
87
110
|
const CHAT_ID=/^(?:-?\d{1,20}|@[A-Za-z][A-Za-z0-9_]{4,31})$/;
|
|
@@ -89,7 +112,7 @@ const SECRET_KEYS=['token','tunnelToken','botToken','secret','apiToken','passwor
|
|
|
89
112
|
function validateConnectors(connectors){
|
|
90
113
|
if(connectors===null)return;
|
|
91
114
|
const bad=invalid('connectors');
|
|
92
|
-
if(!plain(connectors))bad(' must be {
|
|
115
|
+
if(!plain(connectors))bad(' must be {repos?, gateway?, cloudflare?, telegram?} or null.');
|
|
93
116
|
const closed=(node,where,keys)=>{
|
|
94
117
|
if(!plain(node))bad(`.${where} must be a mapping.`);
|
|
95
118
|
for(const key of Object.keys(node)){
|
|
@@ -97,8 +120,7 @@ function validateConnectors(connectors){
|
|
|
97
120
|
if(!keys.includes(key))bad(`.${where} has unknown key ${key} (allowed: ${keys.join(', ')}).`);
|
|
98
121
|
}
|
|
99
122
|
};
|
|
100
|
-
closed(connectors,'',['
|
|
101
|
-
if(connectors.secretsFile!==undefined&&connectors.secretsFile!==null&&(typeof connectors.secretsFile!=='string'||!connectors.secretsFile.trim()))bad('.secretsFile must be a dotenv file path (relative to the skill root) or null.');
|
|
123
|
+
closed(connectors,'',['repos','gateway','cloudflare','telegram']);
|
|
102
124
|
if(connectors.repos!==undefined&&(!Array.isArray(connectors.repos)||connectors.repos.some(repo=>typeof repo!=='string'||!repo.trim())))bad('.repos must be a list of repository paths.');
|
|
103
125
|
if(connectors.gateway!==undefined){
|
|
104
126
|
closed(connectors.gateway,'gateway',['port']);
|
|
@@ -132,46 +154,14 @@ function validateConnectors(connectors){
|
|
|
132
154
|
if(tg.enabled===true&&(tg.chatId===undefined||tg.chatId===null))bad('.telegram.enabled needs telegram.chatId.');
|
|
133
155
|
}
|
|
134
156
|
}
|
|
135
|
-
/**
|
|
136
|
-
|
|
137
|
-
* points at (the custody pointer convention). Returns null when neither is set. Callers pass the value to
|
|
138
|
-
* the one API that needs it and never print it.
|
|
139
|
-
*/
|
|
140
|
-
/** Parse a dotenv file (KEY=VALUE lines, # comments, optional export/quotes). An absent file is {}. */
|
|
141
|
-
export function readDotenv(file){
|
|
142
|
-
let text='';try{text=fs.readFileSync(file,'utf8');}catch(error){if(error?.code==='ENOENT')return {};throw error;}
|
|
143
|
-
const out={};
|
|
144
|
-
for(const line of text.split(/\r?\n/)){
|
|
145
|
-
const m=line.match(/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$/);
|
|
146
|
-
if(!m)continue;
|
|
147
|
-
let value=m[2];
|
|
148
|
-
if(value.length>=2&&(value[0]==='"'||value[0]==="'")&&value.at(-1)===value[0])value=value.slice(1,-1);
|
|
149
|
-
out[m[1]]=value;
|
|
150
|
-
}
|
|
151
|
-
return out;
|
|
152
|
-
}
|
|
153
|
-
/** The dotenv file connectors.secretsFile names, resolved against the skill root; null when unset. */
|
|
154
|
-
const connectorSecretsFile=(config,root=configRoot)=>{const file=config?.connectors?.secretsFile;return typeof file==='string'&&file.trim()?path.resolve(root,file):null;};
|
|
155
|
-
/**
|
|
156
|
-
* The environment the connectors resolve secrets from: connectors.secretsFile's values under the process
|
|
157
|
-
* environment (a real env var wins). It holds secrets — hand it to connectorSecret, never print it.
|
|
158
|
-
*/
|
|
159
|
-
export function connectorEnv(config,env=process.env,root=configRoot){const file=connectorSecretsFile(config,root);return file?{...readDotenv(file),...env}:env;}
|
|
160
|
-
export function connectorSecret(name,env=process.env){
|
|
161
|
-
if(typeof name!=='string'||!ENV_NAME.test(name))return null;
|
|
162
|
-
const direct=typeof env[name]==='string'?env[name].trim():'';
|
|
163
|
-
if(direct)return direct;
|
|
164
|
-
const pointer=typeof env[`${name}_FILE`]==='string'?env[`${name}_FILE`].trim():'';
|
|
165
|
-
if(!pointer)return null;
|
|
166
|
-
try{const value=fs.readFileSync(pointer,'utf8').trim();return value||null;}catch{return null;}
|
|
167
|
-
}
|
|
157
|
+
/** Load connector credentials from the explicitly verified runtime main; never print the returned environment. */
|
|
158
|
+
export function connectorEnv(config,env=process.env,verifiedRuntimeRoot){return secretEnv(verifiedRuntimeRoot,env);}
|
|
168
159
|
/**
|
|
169
160
|
* The normalized `connectors` block: defaults filled in, the secrets' PRESENCE (booleans, never values),
|
|
170
161
|
* whether each connector is ready to run, and the owner-facing warnings the security posture earns.
|
|
171
162
|
*/
|
|
172
163
|
export function connectorsConfig(config=loadConfig(),env=process.env,root=configRoot){
|
|
173
164
|
validateConfig(config);
|
|
174
|
-
env=connectorEnv(config,env,root);
|
|
175
165
|
const raw=plain(config?.connectors)?config.connectors:{},d=CONNECTOR_DEFAULTS;
|
|
176
166
|
const cloudflare={...d.cloudflare,...(raw.cloudflare??{})},telegram={...d.telegram,...(raw.telegram??{})};
|
|
177
167
|
if(cloudflare.credentialsFile)cloudflare.credentialsFile=path.resolve(root,cloudflare.credentialsFile.replace(/^~(?=[\\/])/,env.USERPROFILE??env.HOME??'~'));
|
|
@@ -189,7 +179,7 @@ export function connectorsConfig(config=loadConfig(),env=process.env,root=config
|
|
|
189
179
|
if(cloudflare.auth==='token'&&!cloudflare.tokenPresent)warnings.push(`cloudflare.mode named: the tunnel token is not set — export ${cloudflare.tokenEnv} (or ${cloudflare.tokenEnv}_FILE).`);
|
|
190
180
|
if(telegram.enabled&&!telegram.botTokenPresent)warnings.push(`telegram.enabled: the bot token is not set — export ${telegram.botTokenEnv} (or ${telegram.botTokenEnv}_FILE).`);
|
|
191
181
|
if(telegram.exposeCredentialAsks)warnings.push('telegram.exposeCredentialAsks is true: credential asks are posted as public links; Telegram cloud chats are not end-to-end encrypted.');
|
|
192
|
-
return {
|
|
182
|
+
return {repos:[...(raw.repos??d.repos)],gateway:{...d.gateway,...(raw.gateway??{})},cloudflare,telegram,warnings};
|
|
193
183
|
}
|
|
194
184
|
/**
|
|
195
185
|
* config.yaml `asks` — whether an owner ask that carries a recommended option is answered with it
|
|
@@ -305,7 +295,9 @@ function validateAgentSeat(seat,name,profile){
|
|
|
305
295
|
if(seat.effort!==undefined&&seat.effort!==null&&!EFFORT_LEVELS.includes(seat.effort))throw Error(`Invalid config.yaml: ${name}.effort must use the effort vocabulary.`);
|
|
306
296
|
}
|
|
307
297
|
export function validateConfig(config){
|
|
308
|
-
const allowed=['language','model','effort','models','debug','allocation','kernel','budgets','supervisor','parallel','delegation','connectors','asks','uat','specs','reconciler','coreDebug','orca','roots'],models=config?.models,profile=runtimeProfile(),runtimes=profile?.runtimes??{};
|
|
298
|
+
const allowed=['language','model','effort','models','debug','allocation','kernel','budgets','supervisor','parallel','delegation','connectors','asks','uat','specs','reconciler','coreDebug','orca','roots','launchTrust','retention'],models=config?.models,profile=runtimeProfile(),runtimes=profile?.runtimes??{};
|
|
299
|
+
if(config?.launchTrust!==undefined)launchTrustSettings(config);
|
|
300
|
+
if(config?.retention!==undefined)workflowPurgeSettings(config);
|
|
309
301
|
if(config?.connectors!==undefined)validateConnectors(config.connectors);
|
|
310
302
|
if(config?.asks!==undefined)validateAsks(config.asks);
|
|
311
303
|
if(config?.uat!==undefined)validateUat(config.uat);
|
|
@@ -372,8 +364,8 @@ export function validateConfig(config){
|
|
|
372
364
|
}
|
|
373
365
|
if(config?.budgets!==undefined){
|
|
374
366
|
const budgets=config.budgets;
|
|
375
|
-
if(!plain(budgets)||Object.keys(budgets).some(key=>!['maxOps'
|
|
376
|
-
throw Error('Invalid config.yaml: budgets must be {maxOps
|
|
367
|
+
if(!plain(budgets)||Object.keys(budgets).some(key=>!['maxOps'].includes(key))||Object.values(budgets).some(value=>value!==null&&!(Number.isInteger(value)&&value>0)))
|
|
368
|
+
throw Error('Invalid config.yaml: budgets must be {maxOps?} with positive-integer-or-null values.');
|
|
377
369
|
}
|
|
378
370
|
if(!plain(config)||Object.keys(config).some(key=>!allowed.includes(key))||typeof config.language!=='string'||!/^[a-z]{2,3}(?:-[A-Za-z0-9]+)*$/.test(config.language)||!(config.model===null||typeof config.model==='string'&&config.model.trim())||!EFFORT_LEVELS.includes(config.effort)||!plain(models)||Object.keys(models).some(key=>!['pools','nonOperation','selection'].includes(key))||models.selection!=='quota-aware'||!plain(models.pools)||!plain(models.nonOperation)||Object.keys(models.pools).length!==Object.keys(DEFAULT_MODEL_POOLS).length||Object.keys(models.pools).some(key=>!Object.hasOwn(DEFAULT_MODEL_POOLS,key))||Object.keys(models.nonOperation).length!==Object.keys(NON_OPERATION_ROLES).length||Object.keys(models.nonOperation).some(key=>!Object.hasOwn(NON_OPERATION_ROLES,key)))throw Error('Invalid config.yaml: expected language, model, effort and the closed quota-aware model pools/non-operation role map.');
|
|
379
371
|
for(const [pool,members] of Object.entries(models.pools))if(!Array.isArray(members)||members.length!==2||new Set(members).size!==2||members.some(id=>typeof id!=='string'||!plain(runtimes[id]))||!(members.length===DEFAULT_MODEL_POOLS[pool].length&&members.every(id=>DEFAULT_MODEL_POOLS[pool].includes(id))))throw Error(`Invalid config.yaml: models.pools.${pool} must contain its canonical pair of two unique known runtime ids.`);
|
|
@@ -444,7 +436,7 @@ export function inspectOwnerConfig(root=configRoot){
|
|
|
444
436
|
* or `starci kernel run-deferred-tests`); e2e.verify then runs the FULL e2e suite.
|
|
445
437
|
* An absent, null or unreadable owner file reads as the defaults: harness off, unit on, e2e off.
|
|
446
438
|
*/
|
|
447
|
-
|
|
439
|
+
const SPEC_FAMILIES=Object.freeze(['harness','unit','e2e']);
|
|
448
440
|
export const SPEC_DEFAULTS=Object.freeze({harness:false,unit:true,e2e:false});
|
|
449
441
|
export function specsSettings(config){const specs=plain(config?.specs)?config.specs:{};return Object.fromEntries(SPEC_FAMILIES.map(key=>[key,typeof specs[key]==='boolean'?specs[key]:SPEC_DEFAULTS[key]]));}
|
|
450
442
|
/** specs.harness of the owner file under `root` (tolerant read: inspectOwnerConfig): true only when the owner opted in to `--specs all`. */
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
/** The host-local credential filename, resolved only against a verified runtime main root. */
|
|
5
|
+
export const SECRET_ENV_FILE='secret.env';
|
|
6
|
+
/** The configured credential variable grammar shared by validation and secret resolution. */
|
|
7
|
+
export const ENV_NAME=/^[A-Z_][A-Z0-9_]{0,63}$/;
|
|
8
|
+
export const CREDENTIAL_FILE_MAX_BYTES=1024*1024;
|
|
9
|
+
const resolvedValues = new Set();
|
|
10
|
+
const remember = value => {
|
|
11
|
+
if (typeof value === 'string' && value.length >= 6) resolvedValues.add(value);
|
|
12
|
+
return value;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
/** Filter credentials resolved by this process; the values never leave their resolution owner. */
|
|
16
|
+
export function redactResolvedSecrets(text) {
|
|
17
|
+
let clean = text;
|
|
18
|
+
for (const value of resolvedValues) if (clean.includes(value)) clean = clean.split(value).join('[redacted:resolved-secret]');
|
|
19
|
+
return clean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Check lexical availability only; blank and conventional template markers never prove provider validity. */
|
|
23
|
+
export function credentialPresent(value){
|
|
24
|
+
if(typeof value!=='string'||!value.trim())return false;
|
|
25
|
+
return !/^(?:CHANGE_ME|REPLACE_ME|PLACEHOLDER|TODO|<[^>]*>|YOUR_[A-Z0-9_]+)$/i.test(value.trim());
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Read one configured credential file with a finite byte budget; no effects beyond the read. */
|
|
29
|
+
export function readSecretBytes(file){
|
|
30
|
+
const before=fs.lstatSync(file);
|
|
31
|
+
if(!before.isFile()||before.isSymbolicLink())throw new Error('credential file must be a regular file');
|
|
32
|
+
if(before.size>CREDENTIAL_FILE_MAX_BYTES)throw new Error('credential file exceeds the byte budget');
|
|
33
|
+
const fd=fs.openSync(file,fs.constants.O_RDONLY|(fs.constants.O_NOFOLLOW??0));
|
|
34
|
+
let bytes;
|
|
35
|
+
try{
|
|
36
|
+
const stat=fs.fstatSync(fd);
|
|
37
|
+
if(!stat.isFile()||stat.dev!==before.dev||stat.ino!==before.ino)throw new Error('credential file changed during resolution');
|
|
38
|
+
bytes=Buffer.alloc(CREDENTIAL_FILE_MAX_BYTES+1);
|
|
39
|
+
let count=0;
|
|
40
|
+
while(count<bytes.length){const n=fs.readSync(fd,bytes,count,bytes.length-count,null);if(n===0)break;count+=n;}
|
|
41
|
+
if(count>CREDENTIAL_FILE_MAX_BYTES)throw new Error('credential file exceeds the byte budget');
|
|
42
|
+
return bytes.subarray(0,count);
|
|
43
|
+
}catch(error){bytes?.fill(0);throw error;}finally{fs.closeSync(fd);}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const readCredentialFile = file => { const bytes=readSecretBytes(file); try{return bytes.toString('utf8');}finally{bytes.fill(0);} };
|
|
47
|
+
|
|
48
|
+
/** Parse a dotenv file (KEY=VALUE lines, # comments, optional export/quotes). An absent file is {}. */
|
|
49
|
+
export function readDotenv(file){
|
|
50
|
+
let text='';try{text=readCredentialFile(file);}catch(error){if(error?.code==='ENOENT')return {};throw error;}
|
|
51
|
+
const out={};
|
|
52
|
+
for(const line of text.split(/\r?\n/)){
|
|
53
|
+
const m=line.match(/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$/);
|
|
54
|
+
if(!m)continue;
|
|
55
|
+
let value=m[2];
|
|
56
|
+
if(value.length>=2&&(value[0]==='"'||value[0]==="'")&&value.at(-1)===value[0])value=value.slice(1,-1);
|
|
57
|
+
out[m[1]]=value;
|
|
58
|
+
}
|
|
59
|
+
return out;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Resolve a configured variable or its supported NAME_FILE custody pointer. An explicitly supplied
|
|
64
|
+
* blank variable disables the value; it must not resurrect a file value. Never print the result.
|
|
65
|
+
*/
|
|
66
|
+
export function connectorSecret(name,env=process.env){
|
|
67
|
+
if(typeof name!=='string'||!ENV_NAME.test(name))return null;
|
|
68
|
+
if(Object.hasOwn(env,name))return typeof env[name]==='string'?remember(env[name].trim()||null):null;
|
|
69
|
+
const pointer=typeof env[`${name}_FILE`]==='string'?env[`${name}_FILE`].trim():'';
|
|
70
|
+
if(!pointer)return null;
|
|
71
|
+
try{const value=readCredentialFile(pointer).trim();return remember(value||null);}catch{return null;}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Load the verified runtime main's local credentials below the actual environment without mutating
|
|
76
|
+
* either input. The caller owns repository identity; this base-tier reader never infers a root from cwd.
|
|
77
|
+
* Absence is allowed so the action-specific gate can name only the credentials that action requires.
|
|
78
|
+
*/
|
|
79
|
+
export function secretEnv(verifiedRuntimeRoot,env=process.env){
|
|
80
|
+
if(typeof verifiedRuntimeRoot!=='string'||!path.isAbsolute(verifiedRuntimeRoot))
|
|
81
|
+
throw new TypeError('secretEnv requires an absolute verified runtime root');
|
|
82
|
+
if(env===null||typeof env!=='object'||Array.isArray(env))throw new TypeError('secretEnv requires an environment mapping');
|
|
83
|
+
const root=fs.lstatSync(verifiedRuntimeRoot);
|
|
84
|
+
if(!root.isDirectory()||root.isSymbolicLink())throw new Error('secretEnv runtime root must be a regular directory');
|
|
85
|
+
const file=path.join(verifiedRuntimeRoot,SECRET_ENV_FILE);
|
|
86
|
+
let stat;
|
|
87
|
+
try{stat=fs.lstatSync(file);}catch(error){if(error?.code==='ENOENT')return {...env};throw error;}
|
|
88
|
+
if(!stat.isFile()||stat.isSymbolicLink())throw new Error('secret.env must be a regular file');
|
|
89
|
+
const local = readDotenv(file);
|
|
90
|
+
const resolved = {...local,...env};
|
|
91
|
+
for (const name of Object.keys(local)) if (typeof resolved[name] === 'string') remember(resolved[name]);
|
|
92
|
+
return resolved;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Select a supplied age identity without inventing custody or running a key tool. Inline execution awaits its owned isolation capability. */
|
|
96
|
+
export function sopsIdentityEnv(env,{identity=null,required=false,platform=process.platform}={}){
|
|
97
|
+
const refuse=(reason,message)=>{
|
|
98
|
+
const error=new Error(`SOPS identity [${reason}]: ${message}`);
|
|
99
|
+
error.name='SopsIdentityRefusal';error.identityRefusal=reason;
|
|
100
|
+
return {mode:'refused',env:null,error};
|
|
101
|
+
};
|
|
102
|
+
if(!env||typeof env!=='object'||Array.isArray(env))return refuse('invalid-environment','an environment mapping is required');
|
|
103
|
+
const names=name=>Object.keys(env).filter(key=>platform==='win32'?key.toUpperCase()===name:key===name);
|
|
104
|
+
const inline=names('SOPS_AGE_KEY'),files=names('SOPS_AGE_KEY_FILE');
|
|
105
|
+
if(inline.length>1||files.length>1)return refuse('ambiguous-environment','multiple Windows aliases select an age identity');
|
|
106
|
+
if(inline.length){
|
|
107
|
+
const name=inline[0];
|
|
108
|
+
if(!credentialPresent(env[name]))return refuse('disabled-inline','SOPS_AGE_KEY is explicitly empty or invalid');
|
|
109
|
+
remember(env[name].trim());
|
|
110
|
+
return {...refuse('inline-context-unqualified','the supplied SOPS_AGE_KEY awaits an owned selected-identity isolation capability; no SOPS process was launched'),inlineName:name};
|
|
111
|
+
}
|
|
112
|
+
let file=identity;
|
|
113
|
+
if(files.length){
|
|
114
|
+
const value=env[files[0]];
|
|
115
|
+
if(!credentialPresent(value))return refuse('disabled-file','SOPS_AGE_KEY_FILE is explicitly empty or invalid');
|
|
116
|
+
if(identity!==null&&identity!==undefined&&identity!==value)return refuse('conflicting-files','two different original identity files were explicitly selected');
|
|
117
|
+
file=value;
|
|
118
|
+
}
|
|
119
|
+
if(file!==null&&file!==undefined){
|
|
120
|
+
if(!credentialPresent(file))return refuse('invalid-file','the original identity file selection is empty or invalid');
|
|
121
|
+
if(!path.isAbsolute(file))return refuse('identity-file-location-unqualified','select the original identity by its absolute path; caller and SOPS working directories may differ');
|
|
122
|
+
try{
|
|
123
|
+
const stat=fs.lstatSync(file);
|
|
124
|
+
if(!stat.isFile()||stat.isSymbolicLink())return refuse('identity-file-unavailable','the explicitly selected original identity must be a regular non-symlink file');
|
|
125
|
+
}catch{return refuse('identity-file-unavailable','the explicitly selected original identity file is unavailable');}
|
|
126
|
+
return {mode:'file',env:{...env,SOPS_AGE_KEY_FILE:file},error:null};
|
|
127
|
+
}
|
|
128
|
+
if(required)return refuse('identity-not-supplied','supply SOPS_AGE_KEY locally or select the original SOPS_AGE_KEY_FILE; no home default was inferred');
|
|
129
|
+
return {mode:'public-recipient',env:{...env},error:null};
|
|
130
|
+
}
|
|
@@ -22,68 +22,68 @@ pins:
|
|
|
22
22
|
source: packages/heroicons/package.json
|
|
23
23
|
why: 'StarCi custom Heroicons-compatible cuts; eslint-canon-fe (icon.mjs) admits @starci/heroicons/24/outline and /16/solid as icon sources, so an app that uses them gets this exact version.'
|
|
24
24
|
'@starci/grammar':
|
|
25
|
-
version: 0.8.
|
|
25
|
+
version: 0.8.3
|
|
26
26
|
group: starci
|
|
27
27
|
install: registry
|
|
28
28
|
side: fe
|
|
29
29
|
source: packages/grammar/package.json
|
|
30
30
|
why: the product front ends pinned older versions (0.4.11, 0.5.0, 0.5.1); the runtime source is 0.8.0 and wins for a @starci package (the brand layer sets `--font-sans` and `--font-mono`; the grammar reads them; 0.7.2 added the Input tel kind and IconButton disclosure props).
|
|
31
31
|
'@starci/eslint-canon-be':
|
|
32
|
-
version: 3.0.
|
|
32
|
+
version: 3.0.10
|
|
33
33
|
group: starci
|
|
34
34
|
install: registry
|
|
35
35
|
side: be
|
|
36
36
|
source: packages/eslint/be/package.json
|
|
37
37
|
why: '3.0.3: its bundled canon-pins copy pins stylelint-canon 2.0.2 and hfs 4.0.3; no rule changed. 3.0.2: its bundled runtime copies (slots, canon-pins, failure codes, the architecture machine) put .starcistacks and .sops.yaml at the app root (app.starcistacks, app.sops) and pin hfs 4.0.2. 3.0.1: its bundled canon-pins copy pins hfs 4.0.1. 3.0.0: `loadHfs(import.meta.url)` of be/eslint.config.mjs finds the app-root hfs.json (kind app) and gives every linted file the view of the be side; the project graph is built per side. 2.0.0 (C0 release): starciBeConfig({ hfs: loadHfs(import.meta.url) }) typed factory and the BE-CONVENTION laws.'
|
|
38
38
|
'@starci/eslint-canon-fe':
|
|
39
|
-
version: 8.0.
|
|
39
|
+
version: 8.0.10
|
|
40
40
|
group: starci
|
|
41
41
|
install: registry
|
|
42
42
|
side: fe
|
|
43
43
|
source: packages/eslint/fe/package.json
|
|
44
44
|
why: '8.0.3: its bundled canon-pins copy pins stylelint-canon 2.0.2 and hfs 4.0.3; no rule changed. 8.0.2: its bundled runtime copies (slots, canon-pins, failure codes, the architecture machine) put .starcistacks and .sops.yaml at the app root (app.starcistacks, app.sops) and pin hfs 4.0.2. 8.0.1: its bundled canon-pins copy pins hfs 4.0.1. 8.0.0: `loadHfs(import.meta.url)` of fe/eslint.config.mjs finds the app-root hfs.json (kind app) and gives every linted file the view of the fe side; the project graph is built per side. 7.0.0: the front end has no tests (FE_NO_TESTS R97 in hfs); no-vietnamese-in-source (R91).'
|
|
45
45
|
'@starci/stylelint-canon':
|
|
46
|
-
version: 2.0.
|
|
46
|
+
version: 2.0.5
|
|
47
47
|
group: starci
|
|
48
48
|
install: registry
|
|
49
49
|
side: fe
|
|
50
50
|
source: packages/stylelint/package.json
|
|
51
51
|
why: '2.0.2: no-class-selector refuses a compound class selector (`div.card`), which the 2.0.1 pattern let through. 2.0.1 accepts `--font-sans` and `--font-mono` in the brand layer (the vocabulary follows the grammar 0.8.0). 2.0.0 adds status-contrast (HeroUI soft pairs measured per theme), fixes brand-layer-shape on the shared :root,.light,.dark block and the info soft pair, and derives appTokens with loadAppTokens(import.meta.url) for the managed one-line stylelint.config.mjs.'
|
|
52
52
|
'@starci/tsconfig':
|
|
53
|
-
version: 2.0.
|
|
53
|
+
version: 2.0.3
|
|
54
54
|
group: starci
|
|
55
55
|
install: registry
|
|
56
56
|
side: both
|
|
57
57
|
source: packages/tsconfig/package.json
|
|
58
58
|
'@starci/prettier-config':
|
|
59
|
-
version: 1.0.
|
|
59
|
+
version: 1.0.3
|
|
60
60
|
group: starci
|
|
61
61
|
install: registry
|
|
62
62
|
side: both
|
|
63
63
|
source: packages/prettier-config/package.json
|
|
64
64
|
'@starci/jest-preset':
|
|
65
|
-
version: 2.2.
|
|
65
|
+
version: 2.2.5
|
|
66
66
|
group: starci
|
|
67
67
|
install: registry
|
|
68
68
|
side: be
|
|
69
69
|
source: packages/jest-preset/package.json
|
|
70
70
|
why: '2.2.4: the world runner runs up to min(--maxWorkers, slots) world files at once, each in its own process bound to one data slot of the test world (STARCI_TEST_WORLD_SLOT), and reads test-world state protocol 2 only: pinned together with @starci/test-world 1.1.0, any other state file is JEST_PRESET_WORLD_PAIR_MISMATCH. 2.2.3: republished after the move (no preset change). 2.2.2: the unit run writes the lcov Sonar and Codecov import (services only); 2.2.0: the integration, e2e and contract projects run every spec file in a worker process of its own (world-runner.cjs), so a process-global framework registry (@nestjs/graphql type metadata) never leaks between e2e files; 2.1.0: four projects on the test world, the unit kit (mockEntityManager, fakeTransaction, fakeIds, FakeClock, Outcome matchers) and the recordingOutbox claim side.'
|
|
71
71
|
'@starci/test-world':
|
|
72
|
-
version: 1.2.
|
|
72
|
+
version: 1.2.1
|
|
73
73
|
group: starci
|
|
74
74
|
install: registry
|
|
75
75
|
side: be
|
|
76
76
|
source: packages/test-world/package.json
|
|
77
77
|
why: '1.2.0: Kafka as real own infra (the one digest-pinned apache/kafka KRaft image, a broker listener and proxy per slot, slot-prefixed topics, consumer groups and client ids) and schema-per-context Postgres (a schema and a login role per context in a shared database); its CLI main is exported at ./cli for starci app stack, and the former starci app stack bin is removed. 1.1.0: per-worker data slots: a run provisions min(jest workers, workers cap, default 2) slots, each its own namespace (<ns>_w<k>) with its own databases, Keycloak realm, leased Redis DB, bucket/collection/topic prefixes, fakes host, toxiproxy proxies and outage lock, so world files run in parallel; state file protocol 2, paired exactly with @starci/jest-preset 2.2.4 (TEST_WORLD_PAIR_MISMATCH otherwise). 1.0.6: republished after the move (no library change). 1.0.5: a modules world boots real peer apps beside its modules, world.apps.<name>.during(fn) is the outage of a peer app, and w.keycloak.clientSecret(client) answers the run-generated secret of a confidential realm client. 1.0.4: world.resolve and scope.resolve take any Nest token (class, string or symbol). 1.0.3: the globalSetup registers the path aliases as TypeScript resolves them (the extends chain, paths from the config that declares them, the effective baseUrl), so a tests tsconfig that only extends the side config loads the declaration. 1.0.2: every path a declaration names (`stack`, seeds, the realm, Dockerfiles) resolves from the app root (the directory of hfs.json), where .starcistacks lives, never from the be side. The shared e2e library of every back end (R47, R48): the warm stack behind toxiproxy, the network-edge fakes, the Nest boot, the typed useTestWorld handle, useSandbox for contract specs and the outage lock; a devDependency of every back end. 1.0.1: world.keycloak.events/sessions (the user events of the realm and live sessions through the admin API) and world.infra.postgresql.connection(name) (the database of one connection down while the others serve, under the outage lock).'
|
|
78
78
|
'@starci/cli':
|
|
79
|
-
version: 1.0.
|
|
79
|
+
version: 1.0.1
|
|
80
80
|
group: starci
|
|
81
81
|
install: registry
|
|
82
82
|
side: both
|
|
83
83
|
source: packages/cli/package.json
|
|
84
84
|
why: '1.0.0: the only StarCi command package; product repositories install it for starci app, while @starci/hfs remains its pinned transitive implementation.'
|
|
85
85
|
'@starci/hfs':
|
|
86
|
-
version: 4.0.
|
|
86
|
+
version: 4.0.10
|
|
87
87
|
group: starci
|
|
88
88
|
install: registry
|
|
89
89
|
side: both
|
|
@@ -154,9 +154,6 @@ targets:
|
|
|
154
154
|
# difficulty and whose `defaultModel` covers an unpinned difficulty. A pool target names no model itself.
|
|
155
155
|
codex-agent:
|
|
156
156
|
runtime: codex
|
|
157
|
-
profiles:
|
|
158
|
-
working: gpt-6-luna
|
|
159
|
-
reasoning: gpt-6.1-sol
|
|
160
157
|
orcaLaunch: {agent: codex} # orca orchestration worker-start --agent codex
|
|
161
158
|
# Launch-only targets: an explicit `--model gpt-6.1-sol` or `--model gpt-6-luna` request reaches that one model
|
|
162
159
|
# on the Codex provider window. They are off every automatic chain; allocation routes the codex-agent pool.
|
|
@@ -164,16 +161,10 @@ targets:
|
|
|
164
161
|
gpt-6.1-sol:
|
|
165
162
|
runtime: codex
|
|
166
163
|
defaultModel: gpt-6.1-sol
|
|
167
|
-
profiles:
|
|
168
|
-
working: gpt-6.1-sol
|
|
169
|
-
reasoning: gpt-6.1-sol
|
|
170
164
|
orcaLaunch: {agent: codex} # orca orchestration worker-start --agent codex
|
|
171
165
|
gpt-6-luna:
|
|
172
166
|
runtime: codex
|
|
173
167
|
defaultModel: gpt-6-luna
|
|
174
|
-
profiles:
|
|
175
|
-
working: gpt-6-luna
|
|
176
|
-
reasoning: gpt-6-luna
|
|
177
168
|
orcaLaunch: {agent: codex} # orca orchestration worker-start --agent codex
|
|
178
169
|
# Cursor: Orca agent id `cursor` (binary cursor-agent). A launch-only target like gpt-6.1-sol: off every automatic chain,
|
|
179
170
|
# reached by an explicit `--model auto` (the CLI's default) or any id of the account's `cursor-agent --list-models`.
|
|
@@ -183,43 +174,14 @@ targets:
|
|
|
183
174
|
cursor-agent:
|
|
184
175
|
runtime: cursor
|
|
185
176
|
defaultModel: auto
|
|
186
|
-
profiles:
|
|
187
|
-
working: auto
|
|
188
|
-
reasoning: auto
|
|
189
177
|
orcaLaunch: {agent: cursor} # orca orchestration worker-start --agent cursor
|
|
190
178
|
claude-agent:
|
|
191
179
|
runtime: claude
|
|
192
|
-
profiles:
|
|
193
|
-
working: opus
|
|
194
|
-
reasoning: opus-reviewer
|
|
195
180
|
orcaLaunch: {agent: claude} # orca orchestration worker-start --agent claude
|
|
196
181
|
devin-agent:
|
|
197
182
|
runtime: devin
|
|
198
183
|
executionHosts: [orca]
|
|
199
|
-
profiles:
|
|
200
|
-
working: devin-worker
|
|
201
|
-
reasoning: devin-reviewer
|
|
202
184
|
orcaLaunch: {agent: devin} # orca orchestration worker-start --agent devin
|
|
203
|
-
targetAliases:
|
|
204
|
-
codex-gpt-6.1-sol: codex-agent
|
|
205
|
-
codex-gpt-6-luna: codex-agent
|
|
206
|
-
devin-devin-worker: devin-agent
|
|
207
|
-
devin-devin-reviewer: devin-agent
|
|
208
|
-
# Bare model ids that are not targets resolve to the provider-window pool that carries them, so workflow
|
|
209
|
-
# quota entries land on the right window. gpt-6.1-sol and gpt-6-luna are targets; their `runtime: codex`
|
|
210
|
-
# names the window.
|
|
211
|
-
claude-opus: claude-agent
|
|
212
|
-
claude-opus-5-5: claude-agent
|
|
213
|
-
skills:
|
|
214
|
-
starci:
|
|
215
|
-
chains:
|
|
216
|
-
working:
|
|
217
|
-
- devin-agent
|
|
218
|
-
- codex-agent
|
|
219
|
-
- claude-agent
|
|
220
|
-
reasoning:
|
|
221
|
-
- claude-agent
|
|
222
|
-
- codex-agent
|
|
223
185
|
# An operator whose kind runtimes.yaml roleOfKind marks think work with no declared order carries the
|
|
224
186
|
# think order of runtimes.yaml allocation.preference.think (Opus, then Sol) and nothing else;
|
|
225
187
|
# scripts/route/route-model.mjs refuses any other pool for it. A kind's declared order wins: owner routing
|
|
@@ -524,7 +486,7 @@ selection: |-
|
|
|
524
486
|
operator chain from top to bottom, choosing the first ready runtime/profile. Fall through only
|
|
525
487
|
after an allowed failure with verified no-effect state. This policy never installs an agent,
|
|
526
488
|
authenticates a provider, grants tools or expands the workflow authority. Every pool launches the one
|
|
527
|
-
way (modules/kernel/
|
|
489
|
+
way (modules/kernel/start-workflow.yaml): `orca orchestration worker-start --agent
|
|
528
490
|
<runtime> [--model <id> --effort <level>] --spec <prompt>` (Orca files the Task), the unattended flags from Orca's per-agent
|
|
529
491
|
default args; the launcher takes the agent terminal from the start receipt (else worker-show) and attests the effective agent and model
|
|
530
492
|
(worker-show). Devin takes no model flag (Orca: --model is for Claude, Codex and Cursor) and starts on its CLI default.
|
|
@@ -275,9 +275,6 @@ allocation:
|
|
|
275
275
|
# start waits for an Orca that does not answer, and how long after a responding Orca calls a kernel dead
|
|
276
276
|
# it is asked again.
|
|
277
277
|
hostOutage: {waitMs: 90000, deathSettleMs: 10000}
|
|
278
|
-
# resume: unused since resume-all.mjs was retired (2026-09-28); the logon run waited orcaWaitMs for Orca; the scheduled task
|
|
279
|
-
# re-runs resume every everyMs (whole minutes: schtasks /MO).
|
|
280
|
-
resume: {orcaWaitMs: 600000, everyMs: 600000}
|
|
281
278
|
# supervisorTick: the numbers of the retired supervisor tick (2026-09-28) that the reconciler controllers still read
|
|
282
279
|
# (host thresholds, orca probes, statusApp port, owner digest cadence); everyMs is unused. The tick ran every
|
|
283
280
|
# everyMs (whole minutes: schtasks repetition). host: above maxNode node.exe or maxGit git.exe processes the host
|
|
@@ -354,9 +351,6 @@ allocation:
|
|
|
354
351
|
routeAs: implementation.plan
|
|
355
352
|
minDifficulty: hard
|
|
356
353
|
effort: high
|
|
357
|
-
# restart: unused since restart-all.mjs was retired (2026-09-28); it waited waitMs for every running workflow to show one live
|
|
358
|
-
# Kernel, polling every pollMs.
|
|
359
|
-
restart: {waitMs: 480000, pollMs: 20000}
|
|
360
354
|
# selfReload (scripts/machine/self-reload.mjs): a watchdog loop re-execs at most once per minIntervalMs; its
|
|
361
355
|
# replacement takes over the loop's lock within handoverMs or is stopped.
|
|
362
356
|
selfReload: {minIntervalMs: 300000, handoverMs: 30000}
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
// caller's runner into {ok, stdout, stderr}. Only the call files beside this one import it: each names one git verb
|
|
7
7
|
// (diff.mjs, ls-files.mjs, worktree-*.mjs, ...), and a caller folds the spawn result it gets back with the pure
|
|
8
8
|
// helpers of scripts/lib/git.mjs (gitResultOf, gitOutputOf).
|
|
9
|
+
import { assertMutationFence } from '../../lib/mutation-fence.mjs';
|
|
9
10
|
import { spawnSync } from 'node:child_process';
|
|
10
11
|
import { withoutGitLocalEnv } from '../../lib/git.mjs';
|
|
11
12
|
|
|
@@ -16,6 +17,7 @@ import { withoutGitLocalEnv } from '../../lib/git.mjs';
|
|
|
16
17
|
* ERR_UNKNOWN_ENCODING, so it reaches spawnSync as encoding:null (Buffer output either way).
|
|
17
18
|
*/
|
|
18
19
|
export const gitSpawn = (file, args, options = {}) => {
|
|
20
|
+
assertMutationFence({ kind: 'git-effect', args });
|
|
19
21
|
const spawn = { encoding: 'utf8', windowsHide: true, ...options };
|
|
20
22
|
if (spawn.encoding === 'buffer') spawn.encoding = null;
|
|
21
23
|
return spawnSync(file, args, spawn);
|
|
@@ -10,7 +10,7 @@ import { isFeTestPath } from '../rules/fe-no-tests.mjs';
|
|
|
10
10
|
* root. A file that sits where the slot expects a folder (`hooks/useX.ts`, so the "domain" is a file name) is one too.
|
|
11
11
|
* Files no slot owns are HFS_PATH_NO_SLOT's (`starci app check`), so together every front-end file is placed by exactly one slot and
|
|
12
12
|
* named by it. A test path (a `.spec.tsx` or `.test.tsx` beside a component, a test directory or test tooling) is FE_NO_TESTS's
|
|
13
|
-
* (R97
|
|
13
|
+
* (R97 FE_NO_TESTS): that rule is the one finding of such a file, so this check leaves it alone, as
|
|
14
14
|
* HFS_PATH_NO_SLOT does (scripts/hfs/path-findings.mjs).
|
|
15
15
|
*/
|
|
16
16
|
export const FE_SLOT_ALLOWS_RULE_IDS = ['FE_SLOT_FILE_ROLE'];
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The description form shared by runtime and product public-documentation checks.
|
|
3
|
+
* This proves local form; responsibility, accuracy and important decision rationale remain review.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** Whether AST-selected description text contains a letter or number; language and meaning remain review. */
|
|
7
|
+
export const hasJsdocDescription = (description) => typeof description === "string" && /[\p{L}\p{N}]/u.test(description);
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Read description text before block tags; file-level headers do not describe a declaration.
|
|
11
|
+
* Tag payloads and punctuation cannot make an empty API description pass.
|
|
12
|
+
*/
|
|
13
|
+
export const jsdocDescription = (comment) => {
|
|
14
|
+
if (comment?.type !== "Block" || !comment.value.startsWith("*")) return "";
|
|
15
|
+
const prose = comment.value.replace(/^\*/, "").split(/\r?\n/u)
|
|
16
|
+
.map((line) => line.replace(/^\s*\*(?: ?)/u, "")).join("\n");
|
|
17
|
+
if (/(?:^|\s)@(?:file|fileoverview|overview|license|copyright)\b/u.test(prose)) return "";
|
|
18
|
+
const description = prose.split(/(?:^|\s)@[A-Za-z][\w-]*(?=\s|$)/u, 1)[0].trim();
|
|
19
|
+
return hasJsdocDescription(description) ? description : "";
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Return the final adjacent JSDoc only when it describes this declaration.
|
|
24
|
+
* Decorated classes document the export after its decorators; member docs precede their decorators.
|
|
25
|
+
*/
|
|
26
|
+
export const jsdocBefore = (sourceCode, node) => {
|
|
27
|
+
const exported = node.type === "ExportNamedDeclaration" || node.type === "ExportDefaultDeclaration";
|
|
28
|
+
const anchor = exported
|
|
29
|
+
? sourceCode.getTokens(node).find((token) => token.type === "Keyword" && token.value === "export")
|
|
30
|
+
: node.decorators?.[0] ?? node;
|
|
31
|
+
if (!anchor) return null;
|
|
32
|
+
const comment = sourceCode.getCommentsBefore(anchor).at(-1);
|
|
33
|
+
if (!jsdocDescription(comment)) return null;
|
|
34
|
+
return /^\s*$/u.test(sourceCode.getText().slice(comment.range[1], anchor.range[0])) ? comment : null;
|
|
35
|
+
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// mutation-fence.mjs — ephemeral admission carried through awaited work, never a state store.
|
|
2
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
3
|
+
const scope = new AsyncLocalStorage();
|
|
4
|
+
|
|
5
|
+
/** Run one admitted call; the checker is synchronous and reads its existing durable owners again. */
|
|
6
|
+
export const withMutationFence = (check, authority, fn) => scope.run({ check, authority, checking: false }, fn);
|
|
7
|
+
/** Recheck after the actual transaction lock or immediately before a native effect. */
|
|
8
|
+
export function assertMutationFence(boundary) {
|
|
9
|
+
const current = scope.getStore();
|
|
10
|
+
if (!current || current.checking) return;
|
|
11
|
+
current.checking = true;
|
|
12
|
+
try { current.check(boundary); } finally { current.checking = false; }
|
|
13
|
+
}
|
|
14
|
+
/** Identity stamped by existing write owners, never accepted from a command argument. */
|
|
15
|
+
export const mutationAuthority = () => scope.getStore()?.authority ?? null;
|
|
@@ -54,12 +54,12 @@ export const resolvedKey = (p) => foldCase(path.resolve(p));
|
|
|
54
54
|
/** Whether `a` and `b` resolve to the same path (always case-folded, so differently-cased spellings count). */
|
|
55
55
|
export const sameResolvedPath = (a, b) => pathKey(a, { fold: true }) === pathKey(b, { fold: true });
|
|
56
56
|
/**
|
|
57
|
-
* Whether `file` sits
|
|
58
|
-
* realpath-folded treeKey) when
|
|
57
|
+
* Whether `file` sits under `root`; `includeSelf` also admits the root itself. `key` normalizes
|
|
58
|
+
* both sides first (e.g. the registry's realpath-folded treeKey) when their spellings need it.
|
|
59
59
|
*/
|
|
60
|
-
export const insidePath = (root, file, { key = (p) => p } = {}) => {
|
|
60
|
+
export const insidePath = (root, file, { key = (p) => p, includeSelf = false } = {}) => {
|
|
61
61
|
const rel = path.relative(key(root), key(file));
|
|
62
|
-
return Boolean(rel) && !rel.startsWith('..') && !path.isAbsolute(rel);
|
|
62
|
+
return (includeSelf || Boolean(rel)) && !rel.startsWith('..') && !path.isAbsolute(rel);
|
|
63
63
|
};
|
|
64
64
|
/**
|
|
65
65
|
* A path-ish value (a string or a {path} entry) in relative compare form: trimmed, forward slashes,
|
|
@@ -56,3 +56,57 @@ export function isSopsEnvelope(text) {
|
|
|
56
56
|
}
|
|
57
57
|
try { return sopsTree(parseYaml(src)); } catch { return false; }
|
|
58
58
|
}
|
|
59
|
+
|
|
60
|
+
/** Admit one flat age envelope for the native selected-identity invocation; this is shape admission, never cryptographic proof. */
|
|
61
|
+
export function selectedAgeEnvelope(text, format, recipient) {
|
|
62
|
+
const refuse = reason => ({ ok: false, reason });
|
|
63
|
+
if (!['binary', 'json', 'yaml', 'dotenv'].includes(format)) return refuse('unsupported-format');
|
|
64
|
+
let meta;
|
|
65
|
+
try {
|
|
66
|
+
if (format === 'dotenv') {
|
|
67
|
+
const pairs = new Map();
|
|
68
|
+
for (const line of String(text).split('\n')) {
|
|
69
|
+
if (!line || line.startsWith('#')) continue;
|
|
70
|
+
if (line.includes('\r')) return refuse('unsupported-dotenv');
|
|
71
|
+
const at = line.indexOf('=');
|
|
72
|
+
if (at < 1) return refuse('unsupported-dotenv');
|
|
73
|
+
const name = line.slice(0, at);
|
|
74
|
+
if (pairs.has(name)) return refuse('duplicate-key');
|
|
75
|
+
pairs.set(name, line.slice(at + 1).replaceAll('\\n', '\n'));
|
|
76
|
+
}
|
|
77
|
+
meta = {};
|
|
78
|
+
const age = {};
|
|
79
|
+
for (const [name, value] of pairs) {
|
|
80
|
+
if (!name.startsWith('sops_')) continue;
|
|
81
|
+
const key = name.slice('sops_'.length);
|
|
82
|
+
if (key === 'age__list_0__map_recipient') age.recipient = value;
|
|
83
|
+
else if (key === 'age__list_0__map_enc') age.enc = value;
|
|
84
|
+
else if (key === 'mac_only_encrypted') {
|
|
85
|
+
if (value !== 'false') return refuse('partial-mac');
|
|
86
|
+
meta[key] = false;
|
|
87
|
+
} else if (key.includes('__')) return refuse('unsupported-key-group');
|
|
88
|
+
else meta[key] = value;
|
|
89
|
+
}
|
|
90
|
+
meta.age = [age];
|
|
91
|
+
} else {
|
|
92
|
+
const doc = parseYaml(String(text));
|
|
93
|
+
if (format !== 'yaml') JSON.parse(String(text));
|
|
94
|
+
if (format === 'binary' && (!doc || Object.keys(doc).length !== 2 || !Object.hasOwn(doc, 'data') || !Object.hasOwn(doc, 'sops') || typeof doc.data !== 'string')) return refuse('unsupported-binary-shape');
|
|
95
|
+
meta = doc?.sops;
|
|
96
|
+
}
|
|
97
|
+
} catch { return refuse('invalid-envelope'); }
|
|
98
|
+
if (!meta || typeof meta !== 'object' || Array.isArray(meta) || !isSopsEnvelope(text)) return refuse('invalid-envelope');
|
|
99
|
+
if (Object.hasOwn(meta, 'key_groups') || Object.hasOwn(meta, 'shamir_threshold')) return refuse('unsupported-key-group');
|
|
100
|
+
const providers = ['kms', 'gcp_kms', 'hckms', 'azure_kv', 'hc_vault', 'pgp'];
|
|
101
|
+
for (const name of providers) if (Object.hasOwn(meta, name) && (!Array.isArray(meta[name]) || meta[name].length)) return refuse('non-age-provider');
|
|
102
|
+
const fields = new Set([...providers, 'age', 'lastmodified', 'mac', 'version', 'unencrypted_suffix', 'encrypted_suffix', 'unencrypted_regex', 'encrypted_regex', 'unencrypted_comment_regex', 'encrypted_comment_regex', 'mac_only_encrypted']);
|
|
103
|
+
if (Object.keys(meta).some(name => !fields.has(name))) return refuse('unknown-metadata');
|
|
104
|
+
if (Object.hasOwn(meta, 'mac_only_encrypted') && meta.mac_only_encrypted !== false) return refuse('partial-mac');
|
|
105
|
+
if (!Array.isArray(meta.age) || meta.age.length !== 1) return refuse('unsupported-age-set');
|
|
106
|
+
const entry = meta.age[0];
|
|
107
|
+
if (!entry || typeof entry !== 'object' || Array.isArray(entry) || Object.keys(entry).some(name => !['recipient', 'enc'].includes(name))) return refuse('invalid-age-entry');
|
|
108
|
+
if (typeof entry.recipient !== 'string' || entry.recipient !== recipient) return refuse('recipient-mismatch');
|
|
109
|
+
if (typeof entry.enc !== 'string' || !entry.enc.startsWith('-----BEGIN AGE ENCRYPTED FILE-----\n') || !entry.enc.includes('\n-----END AGE ENCRYPTED FILE-----')) return refuse('invalid-age-entry');
|
|
110
|
+
if (typeof meta.version !== 'string' || !/^\d+\.\d+\.\d+$/.test(meta.version) || typeof meta.lastmodified !== 'string' || !Number.isFinite(Date.parse(meta.lastmodified))) return refuse('invalid-envelope');
|
|
111
|
+
return { ok: true };
|
|
112
|
+
}
|