@starci/eslint-grammar-core 0.15.34 → 0.16.2

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.
Files changed (63) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +1 -1
  3. package/dist/index.js +4 -0
  4. package/dist/lib/why.js +15 -0
  5. package/dist/mascot.d.ts +28 -0
  6. package/dist/mascot.js +166 -0
  7. package/dist/surface-depth.d.ts +7 -0
  8. package/dist/surface-depth.js +92 -0
  9. package/docs/mascot.md +60 -0
  10. package/docs/surface-depth.md +40 -0
  11. package/package.json +2 -2
  12. package/runtime/bundle.json +74 -0
  13. package/runtime/engine/by-code-unit.mjs +6 -6
  14. package/runtime/engine/model-config.mjs +98 -76
  15. package/runtime/engine/orca-config.mjs +22 -17
  16. package/runtime/engine/release-config.mjs +22 -18
  17. package/runtime/engine/removed-vocabulary.mjs +21 -20
  18. package/runtime/engine/resources-config.mjs +42 -35
  19. package/runtime/engine/runtime-root.mjs +11 -13
  20. package/runtime/engine/secrets.mjs +170 -102
  21. package/runtime/engine/sonar-config.mjs +16 -13
  22. package/runtime/knowledge/hfs/canon-pins.yaml +10 -10
  23. package/runtime/knowledge/hfs/rules.yaml +95 -6
  24. package/runtime/knowledge/hfs/slots.yaml +2 -2
  25. package/runtime/knowledge/patterns/fe/ui.yaml +13 -1
  26. package/runtime/scripts/hfs/allows.mjs +37 -38
  27. package/runtime/scripts/hfs/architecture/config.mjs +302 -268
  28. package/runtime/scripts/hfs/architecture/side-boundary.mjs +2 -5
  29. package/runtime/scripts/hfs/architecture/type-context.mjs +255 -250
  30. package/runtime/scripts/hfs/architecture/typescript.mjs +358 -318
  31. package/runtime/scripts/hfs/declaration-shape.mjs +140 -103
  32. package/runtime/scripts/hfs/declaration-slots.mjs +64 -61
  33. package/runtime/scripts/hfs/edition-slots.mjs +43 -40
  34. package/runtime/scripts/hfs/param-names.mjs +6 -11
  35. package/runtime/scripts/hfs/rule-catalog.mjs +353 -257
  36. package/runtime/scripts/hfs/rule-params-shape.mjs +60 -47
  37. package/runtime/scripts/hfs/slot-app-view.mjs +69 -74
  38. package/runtime/scripts/hfs/slot-classify.mjs +42 -38
  39. package/runtime/scripts/hfs/slot-errors.mjs +8 -7
  40. package/runtime/scripts/hfs/slot-imports.mjs +70 -63
  41. package/runtime/scripts/hfs/slot-manifest-shape.mjs +74 -52
  42. package/runtime/scripts/hfs/slot-match.mjs +122 -111
  43. package/runtime/scripts/hfs/slot-path.mjs +0 -2
  44. package/runtime/scripts/hfs/slot-required.mjs +65 -59
  45. package/runtime/scripts/hfs/slot-semantic-problems.mjs +138 -112
  46. package/runtime/scripts/hfs/slot-side-problems.mjs +23 -19
  47. package/runtime/scripts/hfs/slots.mjs +175 -152
  48. package/runtime/scripts/hfs/typescript-programs.mjs +66 -69
  49. package/runtime/scripts/hfs/view.mjs +38 -40
  50. package/runtime/scripts/lib/glob.mjs +23 -14
  51. package/runtime/scripts/lib/grammar-lint.mjs +33 -37
  52. package/runtime/scripts/lib/i18n.mjs +32 -35
  53. package/runtime/scripts/lib/json-clone.mjs +45 -36
  54. package/runtime/scripts/lib/json.mjs +26 -14
  55. package/runtime/scripts/lib/list.mjs +9 -14
  56. package/runtime/scripts/lib/path-key.mjs +53 -38
  57. package/runtime/scripts/lib/read-text.mjs +30 -17
  58. package/runtime/scripts/lib/regex.mjs +1 -2
  59. package/runtime/scripts/lib/source-phrases.mjs +13 -13
  60. package/runtime/scripts/lib/ts-analysis.mjs +28 -26
  61. package/runtime/scripts/lib/ts-ast.mjs +36 -42
  62. package/runtime/scripts/lib/walk.mjs +33 -27
  63. package/runtime/scripts/lib/yaml-cached.mjs +4 -4
@@ -1,90 +1,112 @@
1
1
  import fs from 'node:fs';
2
- import {fileURLToPath} from 'node:url';
3
- import {parseYaml} from './yaml.mjs';
4
- import {isPlainObject as plain} from './plain-object.mjs';
5
- import {invalid} from './invalid-config.mjs';
6
- import {byCodeUnit} from './by-code-unit.mjs';
7
- import {removedOfKind} from './removed-vocabulary.mjs';
8
-
2
+ import { fileURLToPath } from 'node:url';
3
+ import { parseYaml } from './yaml.mjs';
4
+ import { isPlainObject as plain } from './plain-object.mjs';
5
+ import { invalid } from './invalid-config.mjs';
6
+ import { byCodeUnit } from './by-code-unit.mjs';
7
+ import { removedOfKind } from './removed-vocabulary.mjs';
9
8
  /** The effort vocabulary, ordered weakest to strongest — the only list of it. */
10
- export const EFFORT_LEVELS=new Set(['none','minimal','low','medium','high','xhigh','max','ultra']);
11
- const DIFFICULTIES=['easy','medium','hard','insane'];
12
- export const MODELS_KEYS=['tiers','seats','balance','usage'];
13
- const known=names=>[...names].sort(byCodeUnit).join(', ');
14
-
15
- const hasPath=(root,dotted)=>dotted.split('.').reduce((node,key)=>(plain(node)&&Object.hasOwn(node,key)?node[key]:undefined),root)!==undefined;
16
-
9
+ export const EFFORT_LEVELS = new Set(['none', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max', 'ultra']);
10
+ const DIFFICULTIES = ['easy', 'medium', 'hard', 'insane'];
11
+ export const MODELS_KEYS = ['tiers', 'seats', 'balance', 'usage'];
12
+ const known = (names) => [...names].sort(byCodeUnit).join(', ');
13
+ const hasPath = (root, dotted) => dotted.split('.').reduce((node, key) => (plain(node) && Object.hasOwn(node, key) ? node[key] : undefined), root) !== undefined;
17
14
  /** Refuse an old-shape config (the removed keys of modules/kernel/removed-vocabulary.yaml), naming every removed key and where its meaning lives now. */
18
- export function refuseRemovedKeys(config){
19
- const found=removedOfKind('config-key').filter(({name})=>hasPath(config,name));
20
- if(!found.length)return;
21
- const removed=found.map(({name,use})=>`${name} is removed (${use})`).join('; ');
22
- throw new Error(`Invalid config.yaml: ${removed}.`);
15
+ export function refuseRemovedKeys(config) {
16
+ const found = removedOfKind('config-key').filter(({ name }) => hasPath(config, name));
17
+ if (!found.length)
18
+ return;
19
+ const removed = found.map(({ name, use }) => `${name} is removed (${use})`).join('; ');
20
+ throw new Error(`Invalid config.yaml: ${removed}.`);
23
21
  }
24
-
25
- let shippedCache=null;
22
+ let shippedCache = null;
26
23
  /** modules/models/tiers.yaml as shipped, parsed once per file version. */
27
- export function shippedTiers(){
28
- const file=fileURLToPath(new URL('../modules/models/tiers.yaml',import.meta.url));
29
- let stat;try{stat=fs.statSync(file);}catch{throw new Error('Missing modules/models/tiers.yaml');}
30
- const version=`${stat.mtimeMs}:${stat.size}`;
31
- if(shippedCache?.version!==version)shippedCache={version,doc:parseYaml(fs.readFileSync(file,'utf8'))};
32
- return structuredClone(shippedCache.doc);
24
+ export function shippedTiers() {
25
+ const file = fileURLToPath(new URL('../modules/models/tiers.yaml', import.meta.url));
26
+ let stat;
27
+ try {
28
+ stat = fs.statSync(file);
29
+ }
30
+ catch {
31
+ throw new Error('Missing modules/models/tiers.yaml');
32
+ }
33
+ const version = `${stat.mtimeMs}:${stat.size}`;
34
+ if (shippedCache?.version !== version)
35
+ shippedCache = { version, doc: parseYaml(fs.readFileSync(file, 'utf8')) };
36
+ return structuredClone(shippedCache.doc);
33
37
  }
34
-
35
- function validateMember(bad,where,member,profile){
36
- if(!plain(member)||Object.keys(member).some(key=>!['agent','model','effort'].includes(key))||typeof member.agent!=='string'||typeof member.model!=='string')
37
- bad(`${where} must be {agent, model, effort?}.`);
38
- if(profile.models?.[member.model]?.provider!==member.agent)bad(`${where}: model ${member.model} is not declared by agent ${member.agent} in modules/models/registry.yaml models.`);
39
- if(member.effort!==undefined&&!EFFORT_LEVELS.has(member.effort))bad(`${where}.effort must use the effort vocabulary.`);
38
+ function validateMember(bad, where, member, profile) {
39
+ if (!plain(member) || Object.keys(member).some(key => !['agent', 'model', 'effort'].includes(key)) || typeof member.agent !== 'string' || typeof member.model !== 'string')
40
+ return bad(`${where} must be {agent, model, effort?}.`);
41
+ if (profile.models?.[member.model]?.provider !== member.agent)
42
+ bad(`${where}: model ${member.model} is not declared by agent ${member.agent} in modules/models/registry.yaml models.`);
43
+ if (member.effort !== undefined && !(typeof member.effort === 'string' && EFFORT_LEVELS.has(member.effort)))
44
+ bad(`${where}.effort must use the effort vocabulary.`);
40
45
  }
41
-
42
- function validateChains(bad,tiers,profile){
43
- for(const [name,chain] of Object.entries(tiers)){
44
- if(!Array.isArray(chain)||!chain.length)bad(`.${name} must be a non-empty ordered list of {agent, model, effort?}.`);
45
- chain.forEach((member,index)=>validateMember(bad,`.${name}[${index}]`,member,profile));
46
- if(new Set(chain.map(member=>`${member.agent}/${member.model}`)).size!==chain.length)bad(`.${name} names each member once.`);
47
- }
46
+ function validateChains(bad, tiers, profile) {
47
+ for (const [name, chain] of Object.entries(tiers)) {
48
+ if (!Array.isArray(chain) || !chain.length)
49
+ return bad(`.${name} must be a non-empty ordered list of {agent, model, effort?}.`);
50
+ chain.forEach((member, index) => validateMember(bad, `.${name}[${index}]`, member, profile));
51
+ // validateMember refused every member that is not a {agent, model, effort?} record above.
52
+ const members = chain;
53
+ if (new Set(members.map(member => `${member.agent}/${member.model}`)).size !== chain.length)
54
+ bad(`.${name} names each member once.`);
55
+ }
48
56
  }
49
-
50
57
  /** A call tier is taken by a headless call only: no seat, difficulty, kind, order or caller reference may name it, and every call names one. */
51
- function validateUse(bad,{doc,names,refs}){
52
- const callTiers=new Set(Object.entries(doc.tierUse).filter(([,use])=>use==='call').map(([tier])=>tier));
53
- for(const tier of Object.keys(doc.tierUse))if(!names.has(tier))bad(`: tierUse names tier ${tier} (known: ${known(names)}).`);
54
- for(const [where,tier] of refs)if(callTiers.has(tier))bad(`: ${where} names tier ${tier}, a call tier (tierUse): a headless call is made on it, no seat or op is seated on it.`);
55
- for(const [call,spec] of Object.entries(doc.calls))if(!callTiers.has(spec.tier))bad(`: calls.${call}.tier ${spec.tier} must be a tier whose tierUse is call.`);
58
+ function validateUse(bad, { doc, names, refs }) {
59
+ const callTiers = new Set(Object.entries(doc.tierUse).filter(([, use]) => use === 'call').map(([tier]) => tier));
60
+ for (const tier of Object.keys(doc.tierUse))
61
+ if (!names.has(tier))
62
+ bad(`: tierUse names tier ${tier} (known: ${known(names)}).`);
63
+ for (const [where, tier] of refs)
64
+ if (callTiers.has(tier))
65
+ bad(`: ${where} names tier ${tier}, a call tier (tierUse): a headless call is made on it, no seat or op is seated on it.`);
66
+ for (const [call, spec] of Object.entries(doc.calls))
67
+ if (!callTiers.has(spec.tier))
68
+ bad(`: calls.${call}.tier ${spec.tier} must be a tier whose tierUse is call.`);
56
69
  }
57
-
58
70
  /** The tier document the picker reads: the shipped tiers.yaml with config.yaml `models` laid over it. Validated. */
59
- export function effectiveTiers(config,profile){
60
- const doc=shippedTiers(),models=plain(config?.models)?config.models:{};
61
- const bad=invalid('models');
62
- const tiers={...doc.tiers,...models.tiers};
63
- validateChains(bad,tiers,profile);
64
- const seats={...doc.seats,...models.seats};
65
- const balance={...doc.balance,...models.balance};
66
- const usage={...doc.usage,...models.usage};
67
- const names=new Set(Object.keys(tiers));
68
- const refs=[...Object.entries(seats).map(([seat,tier])=>[`.seats.${seat}`,tier]),...DIFFICULTIES.map(level=>[`difficulty.${level}`,doc.difficulty[level]]),
69
- ...Object.entries(doc.kindTiers).map(([kind,tier])=>[`kindTiers.${kind}`,tier]),...doc.tierOrder.map(tier=>['tierOrder',tier])];
70
- for(const [where,tier] of refs)if(!names.has(tier))bad(`: ${where} names tier ${tier} (known: ${known(names)}).`);
71
- validateUse(bad,{doc,names,refs});
72
- if(!(Number.isInteger(balance.maxStreak)&&balance.maxStreak>=1))bad('.balance.maxStreak must be an integer >= 1.');
73
- if(!(typeof balance.maxSharePercent==='number'&&balance.maxSharePercent>0&&balance.maxSharePercent<=100))bad('.balance.maxSharePercent must be a number in (0, 100].');
74
- const {reservePercent,biasPercent,exhaustedPercent}=usage;
75
- if(![reservePercent,biasPercent,exhaustedPercent].every(value=>typeof value==='number'&&value>=0&&value<=100)||!(reservePercent<=biasPercent&&biasPercent<exhaustedPercent))
76
- bad('.usage must hold numbers with reservePercent <= biasPercent < exhaustedPercent, each within 0..100.');
77
- return {...doc,tiers,seats,balance,usage};
71
+ export function effectiveTiers(config, profile) {
72
+ const doc = shippedTiers(), models = plain(config?.models) ? config.models : {};
73
+ const bad = invalid('models');
74
+ const merged = { ...doc.tiers, ...models.tiers };
75
+ validateChains(bad, merged, profile);
76
+ // validateChains refused every chain that is not a non-empty list of {agent, model, effort?} members.
77
+ const tiers = merged;
78
+ const seats = { ...doc.seats, ...models.seats };
79
+ const balance = { ...doc.balance, ...models.balance };
80
+ const usage = { ...doc.usage, ...models.usage };
81
+ const names = new Set(Object.keys(tiers));
82
+ const refs = [...Object.entries(seats).map(([seat, tier]) => [`.seats.${seat}`, tier]), ...DIFFICULTIES.map(level => [`difficulty.${level}`, doc.difficulty[level]]),
83
+ ...Object.entries(doc.kindTiers).map(([kind, tier]) => [`kindTiers.${kind}`, tier]), ...doc.tierOrder.map(tier => ['tierOrder', tier])];
84
+ for (const [where, tier] of refs)
85
+ if (!names.has(tier))
86
+ bad(`: ${where} names tier ${tier} (known: ${known(names)}).`);
87
+ validateUse(bad, { doc, names, refs });
88
+ if (!(Number.isInteger(balance.maxStreak) && balance.maxStreak >= 1))
89
+ bad('.balance.maxStreak must be an integer >= 1.');
90
+ if (!(typeof balance.maxSharePercent === 'number' && balance.maxSharePercent > 0 && balance.maxSharePercent <= 100))
91
+ bad('.balance.maxSharePercent must be a number in (0, 100].');
92
+ const { reservePercent, biasPercent, exhaustedPercent } = usage;
93
+ if (![reservePercent, biasPercent, exhaustedPercent].every(value => typeof value === 'number' && value >= 0 && value <= 100) || !(reservePercent <= biasPercent && biasPercent < exhaustedPercent))
94
+ bad('.usage must hold numbers with reservePercent <= biasPercent < exhaustedPercent, each within 0..100.');
95
+ return { ...doc, tiers, seats, balance, usage };
78
96
  }
79
-
80
97
  /** config.yaml `models` — {tiers?, seats?, balance?, usage?}; the shipped defaults make every key optional. */
81
- export function validateModelsBlock(config,profile){
82
- refuseRemovedKeys(config);
83
- const models=config?.models;
84
- if(models===undefined||models===null)return;
85
- const bad=invalid('models');
86
- if(!plain(models)||Object.keys(models).some(key=>!MODELS_KEYS.includes(key)))bad(` must be {${MODELS_KEYS.map(key=>key+'?').join(', ')}} or null (unknown keys are refused).`);
87
- if(models.tiers!==undefined&&!plain(models.tiers))bad('.tiers must map tier names to ordered member lists.');
88
- for(const key of ['seats','balance','usage'])if(models[key]!==undefined&&!plain(models[key]))bad(`.${key} must be a mapping.`);
89
- effectiveTiers(config,profile);
98
+ export function validateModelsBlock(config, profile) {
99
+ refuseRemovedKeys(config);
100
+ const models = config?.models;
101
+ if (models === undefined || models === null)
102
+ return;
103
+ const bad = invalid('models');
104
+ if (!plain(models) || Object.keys(models).some(key => !MODELS_KEYS.includes(key)))
105
+ return bad(` must be {${MODELS_KEYS.map(key => key + '?').join(', ')}} or null (unknown keys are refused).`);
106
+ if (models.tiers !== undefined && !plain(models.tiers))
107
+ bad('.tiers must map tier names to ordered member lists.');
108
+ for (const key of ['seats', 'balance', 'usage'])
109
+ if (models[key] !== undefined && !plain(models[key]))
110
+ bad(`.${key} must be a mapping.`);
111
+ effectiveTiers(config, profile);
90
112
  }
@@ -1,6 +1,5 @@
1
- import {isPlainObject as plain} from './plain-object.mjs';
2
- import {invalid} from './invalid-config.mjs';
3
-
1
+ import { isPlainObject as plain } from './plain-object.mjs';
2
+ import { invalid } from './invalid-config.mjs';
4
3
  /**
5
4
  * config.yaml `orca` — the owner's Orca app settings the runtime must know. maxWorkerDepth is the deepest worker Orca
6
5
  * lets start under the owner's chat (chat = depth 0, a Kernel or the [Supervisor] 1, an op or a [Worker] 2, the draw
@@ -9,21 +8,27 @@ import {invalid} from './invalid-config.mjs';
9
8
  * deeper than it before worker-start (worker-depth-exceeded), and `start --check` with STARCI_ORCA_LIVE=1 compares it with
10
9
  * a measured probe (scripts/agent/depth-probe.mjs).
11
10
  */
12
- export const ORCA_DEFAULTS=Object.freeze({maxWorkerDepth:4});
11
+ export const ORCA_DEFAULTS = Object.freeze({ maxWorkerDepth: 4 });
13
12
  /** The keys of the orca block. */
14
- export const ORCA_KEYS=Object.freeze(Object.keys(ORCA_DEFAULTS));
15
- export const MAX_WORKER_DEPTH_CEILING=16;
16
- export function validateOrca(orca){
17
- if(orca===null)return;
18
- const bad=invalid('orca');
19
- if(!plain(orca))bad(' must be {maxWorkerDepth?} or null.');
20
- for(const key of Object.keys(orca))if(!ORCA_KEYS.includes(key))bad(` has unknown key ${key} (allowed: ${ORCA_KEYS.join(', ')}).`);
21
- const v=orca.maxWorkerDepth;
22
- if(v!==undefined&&v!==null&&!(Number.isInteger(v)&&v>=1&&v<=MAX_WORKER_DEPTH_CEILING))bad(`.maxWorkerDepth must be an integer from 1 to ${MAX_WORKER_DEPTH_CEILING} equal to the Orca app's worker depth setting (default ${ORCA_DEFAULTS.maxWorkerDepth}), or null.`);
13
+ export const ORCA_KEYS = Object.freeze(Object.keys(ORCA_DEFAULTS));
14
+ export const MAX_WORKER_DEPTH_CEILING = 16;
15
+ export function validateOrca(orca) {
16
+ if (orca === null)
17
+ return;
18
+ const bad = invalid('orca');
19
+ if (!plain(orca))
20
+ return bad(' must be {maxWorkerDepth?} or null.');
21
+ for (const key of Object.keys(orca))
22
+ if (!ORCA_KEYS.includes(key))
23
+ bad(` has unknown key ${key} (allowed: ${ORCA_KEYS.join(', ')}).`);
24
+ const v = orca.maxWorkerDepth;
25
+ if (v !== undefined && v !== null && !(typeof v === 'number' && Number.isInteger(v) && v >= 1 && v <= MAX_WORKER_DEPTH_CEILING))
26
+ bad(`.maxWorkerDepth must be an integer from 1 to ${MAX_WORKER_DEPTH_CEILING} equal to the Orca app's worker depth setting (default ${ORCA_DEFAULTS.maxWorkerDepth}), or null.`);
23
27
  }
24
28
  /** The owner's Orca settings: {maxWorkerDepth, source: 'orca'|'default'}. An absent or null block or key is the default. */
25
- export function orcaSettings(config){
26
- if(config?.orca!==undefined)validateOrca(config.orca);
27
- const value=plain(config?.orca)?config.orca.maxWorkerDepth:null;
28
- return Number.isInteger(value)?{maxWorkerDepth:value,source:'orca'}:{maxWorkerDepth:ORCA_DEFAULTS.maxWorkerDepth,source:'default'};
29
+ export function orcaSettings(config) {
30
+ if (config?.orca !== undefined)
31
+ validateOrca(config.orca);
32
+ const value = plain(config?.orca) ? config.orca.maxWorkerDepth : null;
33
+ return typeof value === 'number' && Number.isInteger(value) ? { maxWorkerDepth: value, source: 'orca' } : { maxWorkerDepth: ORCA_DEFAULTS.maxWorkerDepth, source: 'default' };
29
34
  }
@@ -1,28 +1,32 @@
1
- import {isPlainObject as plain} from './plain-object.mjs';
2
- import {invalid} from './invalid-config.mjs';
3
-
1
+ import { isPlainObject as plain } from './plain-object.mjs';
2
+ import { invalid } from './invalid-config.mjs';
4
3
  /** The keys of the release block. */
5
- export const RELEASE_KEYS=Object.freeze(['suite']);
4
+ export const RELEASE_KEYS = Object.freeze(['suite']);
6
5
  /** Where the full test suite of a release is judged: `local` (the release cut also runs the L4 rows before it tags, the shipped default) or `ci` (the GitHub workflows judge EVERY row on each push to main; the cut runs none). */
7
- export const SUITE_MODES=Object.freeze(['local','ci']);
8
- const RELEASE_DEFAULTS=Object.freeze({suite:'local'});
9
-
6
+ export const SUITE_MODES = Object.freeze(['local', 'ci']);
7
+ const RELEASE_DEFAULTS = Object.freeze({ suite: 'local' });
10
8
  /**
11
9
  * config.yaml `release` - {suite?: local | ci}: who judges the full suite of a release (docs/releasing.md "Where the suite runs"). `local` is the shipped default: `starci release cut` also runs the
12
10
  * L4 rows on this host before it tags. `ci` is the owner's recorded choice (2026-10-09, widened 2026-10-26 by push-main-after-scoped-check): the full suite runs on GitHub only, on every push
13
11
  * to main; the cut runs no row and the verdict is read with `starci release ci-status`. The push of main needs neither mode: it needs a green scoped check.
14
12
  * `none` is refused: the suite does not vanish, it moves to CI, and the value says so.
15
13
  */
16
- export function validateRelease(release){
17
- if(release===null)return;
18
- const bad=invalid('release');
19
- if(!plain(release))bad(` must be {suite?: ${SUITE_MODES.join(' | ')}} or null.`);
20
- for(const key of Object.keys(release))if(!RELEASE_KEYS.includes(key))bad(` has unknown key ${key} (allowed: ${RELEASE_KEYS.join(', ')}).`);
21
- const value=release.suite;
22
- if(value===undefined||value===null)return;
23
- if(value==='none')bad('.suite: "none" is not a mode: write "ci" (no local full suite, CI judges) or "local".');
24
- if(!SUITE_MODES.includes(value))bad(`.suite must be ${SUITE_MODES.join(' or ')}, or null.`);
14
+ export function validateRelease(release) {
15
+ if (release === null)
16
+ return;
17
+ const bad = invalid('release');
18
+ if (!plain(release))
19
+ return bad(` must be {suite?: ${SUITE_MODES.join(' | ')}} or null.`);
20
+ for (const key of Object.keys(release))
21
+ if (!RELEASE_KEYS.includes(key))
22
+ bad(` has unknown key ${key} (allowed: ${RELEASE_KEYS.join(', ')}).`);
23
+ const value = release.suite;
24
+ if (value === undefined || value === null)
25
+ return;
26
+ if (value === 'none')
27
+ bad('.suite: "none" is not a mode: write "ci" (no local full suite, CI judges) or "local".');
28
+ if (typeof value !== 'string' || !SUITE_MODES.includes(value))
29
+ bad(`.suite must be ${SUITE_MODES.join(' or ')}, or null.`);
25
30
  }
26
-
27
31
  /** The suite mode a validated owner config names; an absent block or key is the shipped default. */
28
- export const releaseSuiteMode=config=>config?.release?.suite??RELEASE_DEFAULTS.suite;
32
+ export const releaseSuiteMode = (config) => config?.release?.suite ?? RELEASE_DEFAULTS.suite;
@@ -1,26 +1,27 @@
1
1
  import fs from 'node:fs';
2
- import {fileURLToPath} from 'node:url';
3
- import {parseYaml} from './yaml.mjs';
4
-
5
- const FILE=fileURLToPath(new URL('../modules/kernel/removed-vocabulary.yaml',import.meta.url));
6
-
7
- let cache=null;
8
- function loaded(){
9
- let stat;try{stat=fs.statSync(FILE);}catch{throw new Error('Missing modules/kernel/removed-vocabulary.yaml');}
10
- const version=`${stat.mtimeMs}:${stat.size}`;
11
- if(cache?.version!==version)cache={version,doc:parseYaml(fs.readFileSync(FILE,'utf8'))};
12
- return cache.doc;
2
+ import { fileURLToPath } from 'node:url';
3
+ import { parseYaml } from './yaml.mjs';
4
+ const FILE = fileURLToPath(new URL('../modules/kernel/removed-vocabulary.yaml', import.meta.url));
5
+ let cache = null;
6
+ function loaded() {
7
+ let stat;
8
+ try {
9
+ stat = fs.statSync(FILE);
10
+ }
11
+ catch {
12
+ throw new Error('Missing modules/kernel/removed-vocabulary.yaml');
13
+ }
14
+ const version = `${stat.mtimeMs}:${stat.size}`;
15
+ if (cache?.version !== version)
16
+ cache = { version, doc: parseYaml(fs.readFileSync(FILE, 'utf8')) };
17
+ return cache.doc;
13
18
  }
14
-
15
-
16
19
  /** modules/kernel/removed-vocabulary.yaml: the one list of spellings the runtime refuses, each with its replacement and the release that removed it. */
17
- export const removedVocabulary=()=>loaded().removed;
18
-
20
+ export const removedVocabulary = () => loaded().removed;
19
21
  /** The removed spellings of one kind. */
20
- export const removedOfKind=kind=>removedVocabulary().filter(entry=>entry.kind===kind);
21
-
22
+ export const removedOfKind = (kind) => removedVocabulary().filter(entry => entry.kind === kind);
22
23
  /** `<name> is removed (<replacement>)` for a removed spelling of the kind, or undefined when the name is not one. */
23
- export function removedNotice(kind,name){
24
- const entry=removedOfKind(kind).find(candidate=>candidate.name===name);
25
- return entry?`${entry.name} is removed (${entry.use})`:undefined;
24
+ export function removedNotice(kind, name) {
25
+ const entry = removedOfKind(kind).find(candidate => candidate.name === name);
26
+ return entry ? `${entry.name} is removed (${entry.use})` : undefined;
26
27
  }
@@ -1,6 +1,5 @@
1
- import {isPlainObject as plain} from './plain-object.mjs';
2
- import {invalid} from './invalid-config.mjs';
3
-
1
+ import { isPlainObject as plain } from './plain-object.mjs';
2
+ import { invalid } from './invalid-config.mjs';
4
3
  /**
5
4
  * config.yaml `resources` — the owner's host-capacity floors, which override the shipped numbers of
6
5
  * modules/models/runtimes.yaml `allocation.resources`. minFreeDiskGb: the free space a drive holding the temp root or the
@@ -8,45 +7,53 @@ import {invalid} from './invalid-config.mjs';
8
7
  * percentage of the drive's size; when both are set the larger requirement applies. minFreeRamPct: the free-RAM floor below
9
8
  * which no new heavy op starts. A null or absent key leaves the shipped number.
10
9
  */
11
- const FLOOR_LIMITS=Object.freeze({minFreeDiskGb:Infinity,minFreeDiskPct:100,minFreeRamPct:100});
12
- const inRange=(value,limit)=>typeof value==='number'&&Number.isFinite(value)&&value>0&&value<=limit;
13
-
10
+ const FLOOR_LIMITS = Object.freeze({ minFreeDiskGb: Infinity, minFreeDiskPct: 100, minFreeRamPct: 100 });
11
+ const inRange = (value, limit) => typeof value === 'number' && Number.isFinite(value) && value > 0 && value <= limit;
14
12
  /** The keys of the resources block. */
15
- export const RESOURCE_KEYS=Object.freeze(Object.keys(FLOOR_LIMITS));
16
-
17
- export function validateResources(resources){
18
- if(resources===null)return;
19
- const bad=invalid('resources');
20
- const keys=RESOURCE_KEYS;
21
- const shape=keys.map(key=>`${key}?`).join(', ');
22
- if(!plain(resources))bad(` must be {${shape}} or null.`);
23
- for(const key of Object.keys(resources))if(!keys.includes(key))bad(` has unknown key ${key} (allowed: ${keys.join(', ')}).`);
24
- for(const key of keys){
25
- const value=resources[key];
26
- if(value!==undefined&&value!==null&&!inRange(value,FLOOR_LIMITS[key])){
27
- const cap=FLOOR_LIMITS[key]===Infinity?'':` and at most ${FLOOR_LIMITS[key]}`;
28
- bad(`.${key} must be a number above 0${cap}, or null.`);
13
+ export const RESOURCE_KEYS = Object.freeze(Object.keys(FLOOR_LIMITS));
14
+ const isFloorKey = (key) => Object.hasOwn(FLOOR_LIMITS, key);
15
+ export function validateResources(resources) {
16
+ if (resources === null)
17
+ return;
18
+ const bad = invalid('resources');
19
+ const keys = RESOURCE_KEYS;
20
+ const shape = keys.map(key => `${key}?`).join(', ');
21
+ if (!plain(resources))
22
+ return bad(` must be {${shape}} or null.`);
23
+ for (const key of Object.keys(resources))
24
+ if (!keys.includes(key))
25
+ bad(` has unknown key ${key} (allowed: ${keys.join(', ')}).`);
26
+ for (const key of keys) {
27
+ if (!isFloorKey(key))
28
+ continue;
29
+ const value = resources[key];
30
+ if (value !== undefined && value !== null && !inRange(value, FLOOR_LIMITS[key])) {
31
+ const cap = FLOOR_LIMITS[key] === Infinity ? '' : ` and at most ${FLOOR_LIMITS[key]}`;
32
+ bad(`.${key} must be a number above 0${cap}, or null.`);
33
+ }
29
34
  }
30
- }
31
35
  }
32
-
33
- const declared=(block,key,source)=>{
34
- const value=block?.[key];
35
- if(value===undefined||value===null)return null;
36
- if(!inRange(value,FLOOR_LIMITS[key]))throw new Error(`${source}.${key} must be a number above 0 (at most ${FLOOR_LIMITS[key]})`);
37
- return value;
36
+ const declared = (block, key, source) => {
37
+ const value = block?.[key];
38
+ if (value === undefined || value === null)
39
+ return null;
40
+ if (!inRange(value, FLOOR_LIMITS[key]))
41
+ throw new Error(`${source}.${key} must be a number above 0 (at most ${FLOOR_LIMITS[key]})`);
42
+ return value;
38
43
  };
39
-
40
44
  /**
41
45
  * The floors in force: the owner's `resources` (config.yaml) over the shipped `allocation.resources`.
42
46
  * {minFreeDiskGb, minFreeDiskPct (null when unset), minFreeRamPct}. The shipped policy owns the default numbers: when it
43
47
  * omits or misspells minFreeDiskGb or minFreeRamPct this refuses, there is no fallback constant.
44
48
  */
45
- export function hostFloors(shipped,owner=null){
46
- const source='modules/models/runtimes.yaml allocation.resources';
47
- const base={minFreeDiskGb:declared(shipped,'minFreeDiskGb',source),minFreeDiskPct:declared(shipped,'minFreeDiskPct',source),minFreeRamPct:declared(shipped,'minFreeRamPct',source)};
48
- for(const key of ['minFreeDiskGb','minFreeRamPct'])if(base[key]===null)throw new Error(`${source}.${key} must declare a positive number`);
49
- if(owner!==null&&owner!==undefined)validateResources(owner);
50
- const pick=key=>declared(owner,key,'config.yaml resources')??base[key];
51
- return {minFreeDiskGb:pick('minFreeDiskGb'),minFreeDiskPct:pick('minFreeDiskPct'),minFreeRamPct:pick('minFreeRamPct')};
49
+ export function hostFloors(shipped, owner = null) {
50
+ const source = 'modules/models/runtimes.yaml allocation.resources';
51
+ const base = { minFreeDiskGb: declared(shipped, 'minFreeDiskGb', source), minFreeDiskPct: declared(shipped, 'minFreeDiskPct', source), minFreeRamPct: declared(shipped, 'minFreeRamPct', source) };
52
+ for (const key of ['minFreeDiskGb', 'minFreeRamPct'])
53
+ if (base[key] === null)
54
+ throw new Error(`${source}.${key} must declare a positive number`);
55
+ if (owner !== null && owner !== undefined)
56
+ validateResources(owner);
57
+ const pick = (key) => declared(owner, key, 'config.yaml resources') ?? base[key];
58
+ return { minFreeDiskGb: pick('minFreeDiskGb'), minFreeDiskPct: pick('minFreeDiskPct'), minFreeRamPct: pick('minFreeRamPct') };
52
59
  }
@@ -1,8 +1,7 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
- import {fileURLToPath} from 'node:url';
4
- import {parseYaml} from './yaml.mjs';
5
-
3
+ import { fileURLToPath } from 'node:url';
4
+ import { parseYaml } from './yaml.mjs';
6
5
  /**
7
6
  * Runtime root helper. The runtime reads this source tree directly. `skillRoot` is the
8
7
  * directory containing `package.json` (this module's parent) — the checkout, or the copy of it
@@ -13,7 +12,6 @@ import {parseYaml} from './yaml.mjs';
13
12
  * path under `skillRoot` and parses the file by its extension.
14
13
  */
15
14
  const moduleRoot = path.dirname(fileURLToPath(new URL('../package.json', import.meta.url)));
16
-
17
15
  /** The runtime root: this source tree (or an immutable sealed payload of it). */
18
16
  export const skillRoot = moduleRoot;
19
17
  /**
@@ -21,7 +19,6 @@ export const skillRoot = moduleRoot;
21
19
  * holding this runtime checkout. Parameterized over `env`, so a spec can inject a fixture Source.
22
20
  */
23
21
  export const starciSourceRoot = (env = process.env) => (env.STARCI_SOURCE_ROOT ? path.resolve(env.STARCI_SOURCE_ROOT) : path.dirname(skillRoot));
24
-
25
22
  /** Overrides the per-host state base wholesale (debug probes, spec trees); the one seam starciLocalRoot reads. */
26
23
  export const LOCAL_ROOT_ENV = 'STARCI_LOCAL_ROOT';
27
24
  /** The host-data directory of a runtime root. Git-ignored and never packed; the example fixtures under examples/.runtimes are a separate, public exception. */
@@ -38,17 +35,18 @@ export const runtimeStateDir = (root = skillRoot) => path.join(root, RUNTIME_STA
38
35
  * LOCAL_ROOT_ENV replaces it wholesale. Nothing is read from or moved out of the earlier %LOCALAPPDATA%/StarCi and ~/.starci locations.
39
36
  */
40
37
  export const starciLocalRoot = (env = process.env, root = skillRoot) => (env[LOCAL_ROOT_ENV] ? path.resolve(env[LOCAL_ROOT_ENV]) : runtimeStateDir(root));
41
-
42
38
  /**
43
39
  * Read a runtime contract document. `parts` are path segments under `skillRoot`
44
40
  * (e.g. readModuleJson('modules', 'models', 'kinds.yaml')).
45
41
  */
42
+ // reason: a parsed YAML/JSON contract document read from disk (parsed-input boundary); callers index into it by contract shape.
46
43
  export function readModuleJson(...parts) {
47
- const rel = parts.join('/');
48
- const file = path.join(skillRoot, rel);
49
- if (!(file.startsWith(skillRoot) && fs.existsSync(file) && fs.statSync(file).isFile()))
50
- throw new Error(`Required contract not found for ${rel} under ${skillRoot}`);
51
- const text = fs.readFileSync(file, 'utf8');
52
- if (/\.ya?ml$/i.test(file)) return parseYaml(text);
53
- return JSON.parse(text);
44
+ const rel = parts.join('/');
45
+ const file = path.join(skillRoot, rel);
46
+ if (!(file.startsWith(skillRoot) && fs.existsSync(file) && fs.statSync(file).isFile()))
47
+ throw new Error(`Required contract not found for ${rel} under ${skillRoot}`);
48
+ const text = fs.readFileSync(file, 'utf8');
49
+ if (/\.ya?ml$/i.test(file))
50
+ return parseYaml(text);
51
+ return JSON.parse(text);
54
52
  }