@starci/eslint-grammar-core 0.15.34 → 0.16.0
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/CHANGELOG.md +5 -0
- package/README.md +1 -1
- package/dist/index.js +2 -0
- package/dist/lib/why.js +10 -0
- package/dist/mascot.d.ts +28 -0
- package/dist/mascot.js +166 -0
- package/docs/mascot.md +60 -0
- package/package.json +2 -2
- package/runtime/bundle.json +74 -0
- package/runtime/engine/by-code-unit.mjs +6 -6
- package/runtime/engine/model-config.mjs +98 -76
- package/runtime/engine/orca-config.mjs +22 -17
- package/runtime/engine/release-config.mjs +22 -18
- package/runtime/engine/removed-vocabulary.mjs +21 -20
- package/runtime/engine/resources-config.mjs +42 -35
- package/runtime/engine/runtime-root.mjs +11 -13
- package/runtime/engine/secrets.mjs +170 -102
- package/runtime/engine/sonar-config.mjs +16 -13
- package/runtime/knowledge/hfs/canon-pins.yaml +10 -10
- package/runtime/knowledge/hfs/rules.yaml +72 -6
- package/runtime/knowledge/hfs/slots.yaml +2 -2
- package/runtime/knowledge/patterns/fe/ui.yaml +12 -1
- package/runtime/scripts/hfs/allows.mjs +37 -38
- package/runtime/scripts/hfs/architecture/config.mjs +302 -268
- package/runtime/scripts/hfs/architecture/side-boundary.mjs +2 -5
- package/runtime/scripts/hfs/architecture/type-context.mjs +255 -250
- package/runtime/scripts/hfs/architecture/typescript.mjs +358 -318
- package/runtime/scripts/hfs/declaration-shape.mjs +140 -103
- package/runtime/scripts/hfs/declaration-slots.mjs +64 -61
- package/runtime/scripts/hfs/edition-slots.mjs +43 -40
- package/runtime/scripts/hfs/param-names.mjs +6 -11
- package/runtime/scripts/hfs/rule-catalog.mjs +353 -257
- package/runtime/scripts/hfs/rule-params-shape.mjs +60 -47
- package/runtime/scripts/hfs/slot-app-view.mjs +69 -74
- package/runtime/scripts/hfs/slot-classify.mjs +42 -38
- package/runtime/scripts/hfs/slot-errors.mjs +8 -7
- package/runtime/scripts/hfs/slot-imports.mjs +70 -63
- package/runtime/scripts/hfs/slot-manifest-shape.mjs +74 -52
- package/runtime/scripts/hfs/slot-match.mjs +122 -111
- package/runtime/scripts/hfs/slot-path.mjs +0 -2
- package/runtime/scripts/hfs/slot-required.mjs +65 -59
- package/runtime/scripts/hfs/slot-semantic-problems.mjs +138 -112
- package/runtime/scripts/hfs/slot-side-problems.mjs +23 -19
- package/runtime/scripts/hfs/slots.mjs +175 -152
- package/runtime/scripts/hfs/typescript-programs.mjs +66 -69
- package/runtime/scripts/hfs/view.mjs +38 -40
- package/runtime/scripts/lib/glob.mjs +23 -14
- package/runtime/scripts/lib/grammar-lint.mjs +33 -37
- package/runtime/scripts/lib/i18n.mjs +32 -35
- package/runtime/scripts/lib/json-clone.mjs +45 -36
- package/runtime/scripts/lib/json.mjs +26 -14
- package/runtime/scripts/lib/list.mjs +9 -14
- package/runtime/scripts/lib/path-key.mjs +53 -38
- package/runtime/scripts/lib/read-text.mjs +30 -17
- package/runtime/scripts/lib/regex.mjs +1 -2
- package/runtime/scripts/lib/source-phrases.mjs +13 -13
- package/runtime/scripts/lib/ts-analysis.mjs +28 -26
- package/runtime/scripts/lib/ts-ast.mjs +36 -42
- package/runtime/scripts/lib/walk.mjs +33 -27
- package/runtime/scripts/lib/yaml-cached.mjs +4 -4
|
@@ -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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
6
|
-
|
|
7
|
-
let
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
|
|
25
|
-
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
const
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
}
|
|
@@ -1,134 +1,202 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
-
|
|
4
3
|
/** The host-local credential filename, resolved only against a verified runtime main root. */
|
|
5
|
-
export const SECRET_ENV_FILE='secret.env';
|
|
4
|
+
export const SECRET_ENV_FILE = 'secret.env';
|
|
6
5
|
/** The configured credential variable grammar shared by validation and secret resolution. */
|
|
7
|
-
export const ENV_NAME
|
|
8
|
-
export const CREDENTIAL_FILE_MAX_BYTES=1024*1024;
|
|
6
|
+
export const ENV_NAME = /^[A-Z_][A-Z0-9_]{0,63}$/;
|
|
7
|
+
export const CREDENTIAL_FILE_MAX_BYTES = 1024 * 1024;
|
|
9
8
|
const resolvedValues = new Set();
|
|
10
|
-
const remember = value => {
|
|
11
|
-
|
|
12
|
-
|
|
9
|
+
const remember = (value) => {
|
|
10
|
+
if (typeof value === 'string' && value.length >= 6)
|
|
11
|
+
resolvedValues.add(value);
|
|
12
|
+
return value;
|
|
13
13
|
};
|
|
14
|
-
|
|
15
14
|
/** Filter credentials resolved by this process; the values never leave their resolution owner. */
|
|
16
15
|
export function redactResolvedSecrets(text) {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
16
|
+
let clean = text;
|
|
17
|
+
for (const value of resolvedValues)
|
|
18
|
+
if (clean.includes(value))
|
|
19
|
+
clean = clean.split(value).join('[redacted:resolved-secret]');
|
|
20
|
+
return clean;
|
|
20
21
|
}
|
|
21
|
-
|
|
22
22
|
/** Check lexical availability only; blank and conventional template markers never prove provider validity. */
|
|
23
|
-
export function credentialPresent(value){
|
|
24
|
-
|
|
25
|
-
|
|
23
|
+
export function credentialPresent(value) {
|
|
24
|
+
if (typeof value !== 'string' || !value.trim())
|
|
25
|
+
return false;
|
|
26
|
+
return !/^(?:CHANGE_ME|REPLACE_ME|PLACEHOLDER|TODO|<[^>]*>|YOUR_[A-Z0-9_]+)$/i.test(value.trim());
|
|
26
27
|
}
|
|
27
|
-
|
|
28
28
|
/** Read one configured credential file with a finite byte budget; no effects beyond the read. */
|
|
29
|
-
export function readSecretBytes(file){
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
29
|
+
export function readSecretBytes(file) {
|
|
30
|
+
const before = fs.lstatSync(file);
|
|
31
|
+
if (!before.isFile() || before.isSymbolicLink())
|
|
32
|
+
throw new Error('credential file must be a regular file');
|
|
33
|
+
if (before.size > CREDENTIAL_FILE_MAX_BYTES)
|
|
34
|
+
throw new Error('credential file exceeds the byte budget');
|
|
35
|
+
const fd = fs.openSync(file, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0));
|
|
36
|
+
let bytes;
|
|
37
|
+
try {
|
|
38
|
+
const stat = fs.fstatSync(fd);
|
|
39
|
+
if (!stat.isFile() || stat.dev !== before.dev || stat.ino !== before.ino)
|
|
40
|
+
throw new Error('credential file changed during resolution');
|
|
41
|
+
bytes = Buffer.alloc(CREDENTIAL_FILE_MAX_BYTES + 1);
|
|
42
|
+
let count = 0;
|
|
43
|
+
while (count < bytes.length) {
|
|
44
|
+
const n = fs.readSync(fd, bytes, count, bytes.length - count, null);
|
|
45
|
+
if (n === 0) {
|
|
46
|
+
break;
|
|
47
|
+
}
|
|
48
|
+
count += n;
|
|
49
|
+
}
|
|
50
|
+
if (count > CREDENTIAL_FILE_MAX_BYTES)
|
|
51
|
+
throw new Error('credential file exceeds the byte budget');
|
|
52
|
+
return bytes.subarray(0, count);
|
|
53
|
+
}
|
|
54
|
+
catch (error) {
|
|
55
|
+
bytes?.fill(0);
|
|
56
|
+
throw error;
|
|
57
|
+
}
|
|
58
|
+
finally {
|
|
59
|
+
fs.closeSync(fd);
|
|
60
|
+
}
|
|
44
61
|
}
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
62
|
+
const readCredentialFile = (file) => { const bytes = readSecretBytes(file); try {
|
|
63
|
+
return bytes.toString('utf8');
|
|
64
|
+
}
|
|
65
|
+
finally {
|
|
66
|
+
bytes.fill(0);
|
|
67
|
+
} };
|
|
68
|
+
const DOTENV_LINE_PARTS = [String.raw `^\s*`, String.raw `(?:export\s+)?`, String.raw `([A-Za-z_]\w*)`, String.raw `\s*=\s*`, String.raw `([^\r\n\u2028\u2029]*)`, String.raw `\s*$`];
|
|
49
69
|
const DOTENV_LINE = new RegExp(DOTENV_LINE_PARTS.join(''));
|
|
50
|
-
|
|
51
70
|
/** Parse a dotenv file (KEY=VALUE lines, # comments, optional export/quotes). An absent file is {}. */
|
|
52
|
-
export function readDotenv(file){
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
71
|
+
export function readDotenv(file) {
|
|
72
|
+
let text = '';
|
|
73
|
+
try {
|
|
74
|
+
text = readCredentialFile(file);
|
|
75
|
+
}
|
|
76
|
+
catch (error) {
|
|
77
|
+
if (error?.code === 'ENOENT') {
|
|
78
|
+
return {};
|
|
79
|
+
}
|
|
80
|
+
throw error;
|
|
81
|
+
}
|
|
82
|
+
const out = {};
|
|
83
|
+
for (const line of text.split(/\r?\n/)) {
|
|
84
|
+
const m = DOTENV_LINE.exec(line);
|
|
85
|
+
if (!m)
|
|
86
|
+
continue;
|
|
87
|
+
const name = m[1];
|
|
88
|
+
let value = m[2].trimEnd();
|
|
89
|
+
if (value.length >= 2 && (value.startsWith('"') || value.startsWith("'")) && value.at(-1) === value[0])
|
|
90
|
+
value = value.slice(1, -1);
|
|
91
|
+
out[name] = value;
|
|
92
|
+
}
|
|
93
|
+
return out;
|
|
63
94
|
}
|
|
64
|
-
|
|
65
95
|
/**
|
|
66
96
|
* Resolve a configured variable or its supported NAME_FILE custody pointer. An explicitly supplied
|
|
67
97
|
* blank variable disables the value; it must not resurrect a file value. Never print the result.
|
|
68
98
|
*/
|
|
69
|
-
export function connectorSecret(name,env=process.env){
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
99
|
+
export function connectorSecret(name, env = process.env) {
|
|
100
|
+
if (typeof name !== 'string' || !ENV_NAME.test(name))
|
|
101
|
+
return null;
|
|
102
|
+
const direct = env[name];
|
|
103
|
+
if (Object.hasOwn(env, name))
|
|
104
|
+
return typeof direct === 'string' ? remember(direct.trim() || null) : null;
|
|
105
|
+
const filePointer = env[`${name}_FILE`];
|
|
106
|
+
const pointer = typeof filePointer === 'string' ? filePointer.trim() : '';
|
|
107
|
+
if (!pointer)
|
|
108
|
+
return null;
|
|
109
|
+
try {
|
|
110
|
+
const value = readCredentialFile(pointer).trim();
|
|
111
|
+
return remember(value || null);
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
75
116
|
}
|
|
76
|
-
|
|
77
117
|
/**
|
|
78
118
|
* Load the verified runtime main's local credentials below the actual environment without mutating
|
|
79
119
|
* either input. The caller owns repository identity; this base-tier reader never infers a root from cwd.
|
|
80
120
|
* Absence is allowed so the action-specific gate can name only the credentials that action requires.
|
|
81
121
|
*/
|
|
82
|
-
export function secretEnv(verifiedRuntimeRoot,env=process.env){
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
122
|
+
export function secretEnv(verifiedRuntimeRoot, env = process.env) {
|
|
123
|
+
if (typeof verifiedRuntimeRoot !== 'string' || !path.isAbsolute(verifiedRuntimeRoot))
|
|
124
|
+
throw new TypeError('secretEnv requires an absolute verified runtime root');
|
|
125
|
+
if (env === null || typeof env !== 'object' || Array.isArray(env))
|
|
126
|
+
throw new TypeError('secretEnv requires an environment mapping');
|
|
127
|
+
const root = fs.lstatSync(verifiedRuntimeRoot);
|
|
128
|
+
if (!root.isDirectory() || root.isSymbolicLink())
|
|
129
|
+
throw new Error('secretEnv runtime root must be a regular directory');
|
|
130
|
+
const file = path.join(verifiedRuntimeRoot, SECRET_ENV_FILE);
|
|
131
|
+
let stat;
|
|
132
|
+
try {
|
|
133
|
+
stat = fs.lstatSync(file);
|
|
134
|
+
}
|
|
135
|
+
catch (error) {
|
|
136
|
+
if (error?.code === 'ENOENT') {
|
|
137
|
+
return { ...env };
|
|
138
|
+
}
|
|
139
|
+
throw error;
|
|
140
|
+
}
|
|
141
|
+
if (!stat.isFile() || stat.isSymbolicLink())
|
|
142
|
+
throw new Error('secret.env must be a regular file');
|
|
143
|
+
const local = readDotenv(file);
|
|
144
|
+
const resolved = { ...local, ...env };
|
|
145
|
+
for (const name of Object.keys(local))
|
|
146
|
+
if (typeof resolved[name] === 'string')
|
|
147
|
+
remember(resolved[name]);
|
|
148
|
+
return resolved;
|
|
96
149
|
}
|
|
97
|
-
|
|
98
|
-
const
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
150
|
+
const sopsRefuse = (reason, message) => {
|
|
151
|
+
const error = new Error(`SOPS identity [${reason}]: ${message}`);
|
|
152
|
+
error.name = 'SopsIdentityRefusal';
|
|
153
|
+
error.identityRefusal = reason;
|
|
154
|
+
return { mode: 'refused', env: null, error };
|
|
102
155
|
};
|
|
103
|
-
const sopsInlineIdentity=(env,name)=>{
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
156
|
+
const sopsInlineIdentity = (env, name) => {
|
|
157
|
+
const inline = env[name];
|
|
158
|
+
if (!credentialPresent(inline))
|
|
159
|
+
return sopsRefuse('disabled-inline', 'SOPS_AGE_KEY is explicitly empty or invalid');
|
|
160
|
+
remember(inline.trim());
|
|
161
|
+
return { ...sopsRefuse('inline-context-unqualified', 'the supplied SOPS_AGE_KEY awaits an owned selected-identity isolation capability; no SOPS process was launched'), inlineName: name };
|
|
107
162
|
};
|
|
108
|
-
const sopsFileIdentity=(env,file)=>{
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
163
|
+
const sopsFileIdentity = (env, file) => {
|
|
164
|
+
if (!credentialPresent(file))
|
|
165
|
+
return sopsRefuse('invalid-file', 'the original identity file selection is empty or invalid');
|
|
166
|
+
if (!path.isAbsolute(file))
|
|
167
|
+
return sopsRefuse('identity-file-location-unqualified', 'select the original identity by its absolute path; caller and SOPS working directories may differ');
|
|
168
|
+
try {
|
|
169
|
+
const stat = fs.lstatSync(file);
|
|
170
|
+
if (!stat.isFile() || stat.isSymbolicLink())
|
|
171
|
+
return sopsRefuse('identity-file-unavailable', 'the explicitly selected original identity must be a regular non-symlink file');
|
|
172
|
+
}
|
|
173
|
+
catch {
|
|
174
|
+
return sopsRefuse('identity-file-unavailable', 'the explicitly selected original identity file is unavailable');
|
|
175
|
+
}
|
|
176
|
+
return { mode: 'file', env: { ...env, SOPS_AGE_KEY_FILE: file }, error: null };
|
|
116
177
|
};
|
|
117
178
|
/** Select a supplied age identity without inventing custody or running a key tool. Inline execution awaits its owned isolation capability. */
|
|
118
|
-
export function sopsIdentityEnv(env,{identity=null,required=false,platform=process.platform}={}){
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
if
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
179
|
+
export function sopsIdentityEnv(env, { identity = null, required = false, platform = process.platform } = {}) {
|
|
180
|
+
if (!env || typeof env !== 'object' || Array.isArray(env))
|
|
181
|
+
return sopsRefuse('invalid-environment', 'an environment mapping is required');
|
|
182
|
+
const names = (name) => Object.keys(env).filter(key => platform === 'win32' ? key.toUpperCase() === name : key === name);
|
|
183
|
+
const inline = names('SOPS_AGE_KEY'), files = names('SOPS_AGE_KEY_FILE');
|
|
184
|
+
if (inline.length > 1 || files.length > 1)
|
|
185
|
+
return sopsRefuse('ambiguous-environment', 'multiple Windows aliases select an age identity');
|
|
186
|
+
if (inline.length)
|
|
187
|
+
return sopsInlineIdentity(env, inline[0]);
|
|
188
|
+
let file = identity;
|
|
189
|
+
if (files.length) {
|
|
190
|
+
const value = env[files[0]];
|
|
191
|
+
if (!credentialPresent(value))
|
|
192
|
+
return sopsRefuse('disabled-file', 'SOPS_AGE_KEY_FILE is explicitly empty or invalid');
|
|
193
|
+
if (identity !== null && identity !== undefined && identity !== value)
|
|
194
|
+
return sopsRefuse('conflicting-files', 'two different original identity files were explicitly selected');
|
|
195
|
+
file = value;
|
|
196
|
+
}
|
|
197
|
+
if (file !== null && file !== undefined)
|
|
198
|
+
return sopsFileIdentity(env, file);
|
|
199
|
+
if (required)
|
|
200
|
+
return sopsRefuse('identity-not-supplied', 'supply SOPS_AGE_KEY locally or select the original SOPS_AGE_KEY_FILE; no home default was inferred');
|
|
201
|
+
return { mode: 'public-recipient', env: { ...env }, error: null };
|
|
134
202
|
}
|