@starci/hfs 4.7.44 → 4.7.46
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 +9 -0
- package/lint/run.mjs +46 -10
- package/package.json +4 -1
- package/release/candidate-ci.mjs +7 -7
- package/release/push.mjs +3 -3
- package/runtime/config.example.yaml +9 -10
- package/runtime/engine/config.mjs +3 -3
- package/runtime/engine/release-config.mjs +5 -5
- package/runtime/knowledge/grammars/offset-pop/playbook.yaml +1 -1
- package/runtime/knowledge/grammars/starci/playbook.yaml +27 -3
- package/runtime/knowledge/hfs/canon-pins.yaml +14 -7
- package/runtime/knowledge/hfs/ci-standard.yaml +1 -1
- package/runtime/knowledge/hfs/fe-stack.yaml +12 -1
- package/runtime/knowledge/hfs/rules.yaml +41 -6
- package/runtime/knowledge/hfs/slots.yaml +5 -5
- package/runtime/knowledge/patterns/be/realtime.yaml +72 -12
- package/runtime/modules/kernel/command-policy.yaml +5 -5
- package/runtime/modules/kernel/failure-codes.yaml +42 -20
- package/runtime/modules/kernel/removed-vocabulary.yaml +33 -7
- package/runtime/packages/cli/src/catalog.generated.mjs +90 -37
- package/runtime/scripts/api/git/commit-tree.mjs +5 -0
- package/runtime/scripts/api/git/update-ref.mjs +8 -0
- package/runtime/scripts/api/git/write-tree.mjs +5 -0
- package/runtime/scripts/guards/command-policy.mjs +4 -4
- package/runtime/scripts/guards/push-scan.mjs +128 -0
- package/runtime/scripts/guards/release-push-gate.mjs +31 -29
- package/runtime/scripts/hfs/architecture/index.mjs +2 -1
- package/runtime/scripts/hfs/architecture/type-context.mjs +38 -23
- package/runtime/scripts/hfs/example-secrets.mjs +61 -8
- package/runtime/scripts/hfs/rules/deploy-workflow.mjs +5 -5
- package/runtime/scripts/hfs/runtime-rules/git-triggers.mjs +61 -0
- package/runtime/scripts/hfs/runtime-rules/hook-shape.mjs +4 -6
- package/runtime/scripts/hfs/typescript-programs.mjs +30 -3
- package/runtime/scripts/machine/home.mjs +2 -2
- package/templates/app/ci-workflows/github/workflows/ci.yml +2 -2
- package/templates/app/ci-workflows-lite/github/workflows/ci.yml +2 -2
- package/templates/app/hooks/husky/pre-push +3 -56
- package/templates/be/inbox/platform/inbox/inbox.port.ts.tpl +1 -3
- package/templates/be/patterns/idempotency/platform/inbox/inbox.port.ts.tpl +1 -3
- package/templates/be/patterns/realtime/realtime-config.mapper.ts.tpl +32 -0
- package/runtime/scripts/api/fs/write-json-file.mjs +0 -10
- package/runtime/scripts/api/git/merge-base-query.mjs +0 -5
- package/runtime/scripts/gates/commit-proof.mjs +0 -53
- package/runtime/scripts/guards/release-definition.mjs +0 -204
- package/runtime/scripts/guards/release-record.mjs +0 -58
- package/runtime/scripts/guards/release-suite-mode.mjs +0 -15
- package/runtime/scripts/hfs/runtime-rules/release-notes.mjs +0 -44
|
@@ -103,36 +103,35 @@ function createProjects(config, paths, state, selection, ts, helpers) {
|
|
|
103
103
|
return projects;
|
|
104
104
|
}
|
|
105
105
|
|
|
106
|
+
function ownershipRank(config, project, file, helpers) {
|
|
107
|
+
return helpers.isInside(path.dirname(path.join(config.root, ...project.relative.split('/'))), file)
|
|
108
|
+
? project.relative.split('/').length : -1;
|
|
109
|
+
}
|
|
110
|
+
|
|
106
111
|
function collectProjectFiles(config, projects, helpers) {
|
|
107
|
-
const
|
|
108
|
-
const
|
|
112
|
+
const fileMap = new Map();
|
|
113
|
+
const ownerByFile = new Map();
|
|
114
|
+
// Retain only the winning source occurrence while visiting each project. Equal ranks keep the first project,
|
|
115
|
+
// as does a file outside every project directory; the deepest enclosing tsconfig otherwise owns its checker.
|
|
116
|
+
// Within that project the last realpath alias supplies the source, matching the compiler's occurrence order.
|
|
109
117
|
for (const project of projects) {
|
|
110
|
-
for (const sourceFile of project.program.getSourceFiles()
|
|
118
|
+
for (const sourceFile of project.program.getSourceFiles()) {
|
|
119
|
+
if (!helpers.isProductionSource(config.root, sourceFile)) continue;
|
|
111
120
|
const name = helpers.canonical(sourceFile.fileName);
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
121
|
+
const owner = ownerByFile.get(name);
|
|
122
|
+
if (owner === project) { fileMap.set(name, sourceFile); continue; }
|
|
123
|
+
if (owner && ownershipRank(config, owner, name, helpers) >= ownershipRank(config, project, name, helpers)) continue;
|
|
124
|
+
ownerByFile.set(name, project);
|
|
125
|
+
fileMap.set(name, sourceFile);
|
|
115
126
|
}
|
|
116
127
|
}
|
|
117
|
-
return {
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
function owningProject(config, candidates, file, helpers) {
|
|
121
|
-
return candidates.filter(item => helpers.isInside(path.dirname(path.join(config.root, ...item.relative.split('/'))), file))
|
|
122
|
-
.sort((x, y) => y.relative.split('/').length - x.relative.split('/').length)[0] ?? candidates[0];
|
|
128
|
+
return { fileMap, ownerByFile };
|
|
123
129
|
}
|
|
124
130
|
|
|
125
131
|
function makeFileGraph(config, projects, helpers) {
|
|
126
|
-
const {
|
|
127
|
-
const fileMap = new Map();
|
|
132
|
+
const { fileMap, ownerByFile } = collectProjectFiles(config, projects, helpers);
|
|
128
133
|
const checkerByFile = new Map();
|
|
129
|
-
const
|
|
130
|
-
for (const [name, candidates] of occurrences) {
|
|
131
|
-
const owner = owningProject(config, candidates, name, helpers);
|
|
132
|
-
ownerByFile.set(name, owner);
|
|
133
|
-
fileMap.set(name, sourceIn.get(`${owner.relative}|${name}`));
|
|
134
|
-
checkerByFile.set(name, owner.program.getTypeChecker());
|
|
135
|
-
}
|
|
134
|
+
for (const [name, owner] of ownerByFile) checkerByFile.set(name, owner.program.getTypeChecker());
|
|
136
135
|
const files = [...fileMap.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([, file]) => file);
|
|
137
136
|
const edges = new Map([...fileMap.keys()].map(file => [file, []]));
|
|
138
137
|
for (const [file, sourceFile] of fileMap) {
|
|
@@ -142,6 +141,21 @@ function makeFileGraph(config, projects, helpers) {
|
|
|
142
141
|
return { fileMap, checkerByFile, ownerByFile, files, edges };
|
|
143
142
|
}
|
|
144
143
|
|
|
144
|
+
// Resolution entries contain failed lookup paths as well as hits. Keep only one bounded batch for the current project;
|
|
145
|
+
// graph edges retain the resolved targets, so rotating this scratch cache changes neither coverage nor findings.
|
|
146
|
+
const RESOLUTION_BATCH_SIZE = 1024;
|
|
147
|
+
|
|
148
|
+
function resolutionCache(state, project) {
|
|
149
|
+
if (state.resolutionProject !== project || state.resolutionCount === RESOLUTION_BATCH_SIZE) {
|
|
150
|
+
state.resolutionProject = project;
|
|
151
|
+
state.resolutionCount = 0;
|
|
152
|
+
state.resolutionCache = state.ts.createModuleResolutionCache(state.config.root,
|
|
153
|
+
file => state.ts.sys.useCaseSensitiveFileNames ? file : file.toLowerCase(), project.options);
|
|
154
|
+
}
|
|
155
|
+
state.resolutionCount += 1;
|
|
156
|
+
return state.resolutionCache;
|
|
157
|
+
}
|
|
158
|
+
|
|
145
159
|
function addUnprovenFindings(sourceFile, project, references, errors, config, helpers) {
|
|
146
160
|
for (const item of references.unproven) errors.push({
|
|
147
161
|
ruleId: 'ARCH_DYNAMIC_DEPENDENCY_UNPROVEN',
|
|
@@ -229,7 +243,7 @@ function processReference(from, sourceFile, project, owningWorkspace, reference,
|
|
|
229
243
|
const { config, ts, workspaceNames, workspaces, host, errors, helpers } = state;
|
|
230
244
|
// A workspace package is read at its source, never at its build: the same import resolves whether or not dist exists.
|
|
231
245
|
const workspaceTarget = workspaces.filter(item => item.root !== helpers.canonical(config.root)).map(item => helpers.workspaceSourceEntry(ts, item, reference.specifier)).find(Boolean);
|
|
232
|
-
const resolvedName = workspaceTarget ?? helpers.resolveTypeScriptModule(ts, reference.specifier, sourceFile.fileName, project.options, host);
|
|
246
|
+
const resolvedName = workspaceTarget ?? helpers.resolveTypeScriptModule(ts, reference.specifier, sourceFile.fileName, project.options, host, resolutionCache(state, project));
|
|
233
247
|
if (!resolvedName) {
|
|
234
248
|
unresolvedReferenceFinding(reference, sourceFile, project, config, workspaceNames, errors, helpers);
|
|
235
249
|
return;
|
|
@@ -279,7 +293,8 @@ export function contextFromProjects(config, loaded, paths, helpers) {
|
|
|
279
293
|
const fileGraph = makeFileGraph(config, projects, helpers);
|
|
280
294
|
const { edges, workspaces } = dependencyGraph(config, loaded, fileGraph, fileGraph.ownerByFile, errors, helpers);
|
|
281
295
|
if (projects.length && fileGraph.files.length === 0) errors.push({ ruleId: 'ARCH_NO_SOURCE', message: 'The configured TypeScript projects contain no production TypeScript or JavaScript source.' });
|
|
296
|
+
const { checkerByFile } = fileGraph;
|
|
282
297
|
return { loaded, errors, files: fileGraph.files, edges, programs: projects.map(item => item.program), program: projects[0]?.program ?? null, projects, ts, workspaces,
|
|
283
|
-
checkerFor: file =>
|
|
298
|
+
checkerFor: file => checkerByFile.get(helpers.canonical(file)) ?? null,
|
|
284
299
|
workspaceOf: file => helpers.workspaceOf(workspaces, helpers.canonical(file)) };
|
|
285
300
|
}
|
|
@@ -6,6 +6,11 @@ import path from 'node:path';
|
|
|
6
6
|
import { runProgram } from '../api/process/run-program.mjs';
|
|
7
7
|
import { init as initGit } from '../api/git/init.mjs';
|
|
8
8
|
import { add as addGit } from '../api/git/add.mjs';
|
|
9
|
+
import { revParseQuery } from '../api/git/rev-parse-query.mjs';
|
|
10
|
+
import { mergeBase } from '../api/git/merge-base.mjs';
|
|
11
|
+
import { writeTree } from '../api/git/write-tree.mjs';
|
|
12
|
+
import { commitTree } from '../api/git/commit-tree.mjs';
|
|
13
|
+
import { updateRef } from '../api/git/update-ref.mjs';
|
|
9
14
|
import { stringifyYaml } from '../../engine/yaml.mjs';
|
|
10
15
|
import { isMain } from '../lib/is-main.mjs';
|
|
11
16
|
import { readEnv } from '../lib/env.mjs';
|
|
@@ -50,7 +55,42 @@ export async function materializeExample(repoRoot, { createIdentity = generateAp
|
|
|
50
55
|
}
|
|
51
56
|
}
|
|
52
57
|
|
|
53
|
-
|
|
58
|
+
const COMMITTER = { 'user.name': 'starci', 'user.email': 'l4@starci.invalid' };
|
|
59
|
+
const EMPTY_TREE = '4b825dc642cb6eb9a060e54bf8d69288fbee4904';
|
|
60
|
+
const ok = (r) => !r.error && r.status === 0 && Boolean(String(r.stdout ?? '').trim());
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The lint base the copy's size-growth ratchet measures against: the source app's subtree at the source repository's
|
|
64
|
+
* lint baseline (STARCI_BASE_REF, then origin/main, then main),
|
|
65
|
+
* re-committed inside the copy so `merge-base` and `git show` resolve here. The source objects are reached through an
|
|
66
|
+
* objects alternate, never copied. An app absent at the baseline bases on the empty tree: every file is new.
|
|
67
|
+
* Returns the synthetic base commit sha, or null when the source sits in no repository. Missing repository baselines fail explicitly.
|
|
68
|
+
*/
|
|
69
|
+
function sealedLintBase(repoRoot, source, env) {
|
|
70
|
+
const query = { cwd: source, env };
|
|
71
|
+
const top = revParseQuery(['--show-toplevel'], query);
|
|
72
|
+
if (!ok(top)) return null;
|
|
73
|
+
const common = revParseQuery(['--git-common-dir'], query);
|
|
74
|
+
if (!ok(common)) return null;
|
|
75
|
+
const candidates = [...(env.STARCI_BASE_REF ? [env.STARCI_BASE_REF] : []), 'origin/main', 'main'];
|
|
76
|
+
let baseSha = null;
|
|
77
|
+
for (const ref of candidates) {
|
|
78
|
+
baseSha = mergeBase(source, 'HEAD', ref);
|
|
79
|
+
if (baseSha) break;
|
|
80
|
+
}
|
|
81
|
+
if (!baseSha) throw new Error('sealed lint baseline unavailable: set STARCI_BASE_REF to a source commit sharing history with HEAD, or fetch origin/main or main');
|
|
82
|
+
const relative = path.relative(top.stdout.trim(), path.resolve(source)).split(path.sep).join('/');
|
|
83
|
+
const tree = revParseQuery(['--verify', '--quiet', relative && !relative.startsWith('..') ? `${baseSha}:${relative}` : `${baseSha}^{tree}`], query);
|
|
84
|
+
const baseTree = ok(tree) ? tree.stdout.trim() : EMPTY_TREE;
|
|
85
|
+
const info = path.join(repoRoot, '.git', 'objects', 'info');
|
|
86
|
+
fs.mkdirSync(info, { recursive: true });
|
|
87
|
+
fs.appendFileSync(path.join(info, 'alternates'), `${path.resolve(source, common.stdout.trim()).split(path.sep).join('/')}/objects\n`);
|
|
88
|
+
const base = commitTree([baseTree, '-m', 'sealed lint base'], { cwd: repoRoot, env, config: COMMITTER });
|
|
89
|
+
if (!ok(base)) throw new Error('could not commit the lint base of the isolated example git tree');
|
|
90
|
+
return base.stdout.trim();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function trackExample(repoRoot, source) {
|
|
54
94
|
const env = withoutGitLocalEnv(process.env);
|
|
55
95
|
if (!initGit(repoRoot, { env }).ok) throw new Error('could not initialize the isolated example git tree');
|
|
56
96
|
// The linked node_modules (linkExampleDependencies: a junction on Windows, a symlink elsewhere) is the install, never a tracked entry: git treats a
|
|
@@ -59,6 +99,16 @@ function trackExample(repoRoot) {
|
|
|
59
99
|
fs.appendFileSync(path.join(repoRoot, '.git', 'info', 'exclude'), ['', '# the linked install of the example (example-secrets.mjs)', 'node_modules', ''].join('\n'));
|
|
60
100
|
const staged = addGit(['-A', '--', '.'], { cwd: repoRoot, env });
|
|
61
101
|
if (staged.error || staged.status !== 0) throw new Error('could not stage the isolated example git tree');
|
|
102
|
+
// The isolated tree is committed (and based on the source's lint baseline) so the size-growth ratchet reads a real
|
|
103
|
+
// recorded size instead of failing "baseline unavailable" in every sealed lint, as it did on a historyless tree.
|
|
104
|
+
const base = sealedLintBase(repoRoot, source, env);
|
|
105
|
+
const tree = writeTree([], { cwd: repoRoot, env });
|
|
106
|
+
if (!ok(tree)) throw new Error('could not write the isolated example git tree');
|
|
107
|
+
const committed = commitTree([tree.stdout.trim(), ...(base ? ['-p', base] : []), '-m', 'sealed snapshot'], { cwd: repoRoot, env, config: COMMITTER });
|
|
108
|
+
if (!ok(committed)) throw new Error('could not commit the isolated example git tree');
|
|
109
|
+
const moved = updateRef(repoRoot, 'HEAD', committed.stdout.trim());
|
|
110
|
+
if (!moved.ok) throw new Error('could not point the isolated example git tree at its snapshot commit');
|
|
111
|
+
return base;
|
|
62
112
|
}
|
|
63
113
|
|
|
64
114
|
function safeExampleSource(source, { preserveCoverage = false } = {}) {
|
|
@@ -82,7 +132,7 @@ function pruneEmptyDirectories(directory) {
|
|
|
82
132
|
if (!fs.readdirSync(directory).length) fs.rmdirSync(directory);
|
|
83
133
|
}
|
|
84
134
|
|
|
85
|
-
/** Await callback(tempExamplePath), return its result, and remove the os.tmpdir-based copy even on failure. */
|
|
135
|
+
/** Await callback(tempExamplePath, lintBaseRef), return its result, and remove the os.tmpdir-based copy even on failure. */
|
|
86
136
|
export async function withMaterializedExample(repoRoot, exampleRelativePath, callback, { materialize = materializeExample, linkDependencies = false, preserveCoverage = false } = {}) {
|
|
87
137
|
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'starci-example-'));
|
|
88
138
|
const source = path.resolve(repoRoot, exampleRelativePath);
|
|
@@ -96,8 +146,8 @@ export async function withMaterializedExample(repoRoot, exampleRelativePath, cal
|
|
|
96
146
|
pruneEmptyDirectories(app);
|
|
97
147
|
if (linkDependencies) linkExampleDependencies(source, app);
|
|
98
148
|
await materialize(app);
|
|
99
|
-
trackExample(app);
|
|
100
|
-
return await callback(app);
|
|
149
|
+
const lintBaseRef = trackExample(app, source);
|
|
150
|
+
return await callback(app, lintBaseRef);
|
|
101
151
|
} finally {
|
|
102
152
|
safeRemove(directory, { hold: () => null });
|
|
103
153
|
}
|
|
@@ -133,16 +183,19 @@ function commandLaunch(command, args) {
|
|
|
133
183
|
return { file: process.execPath, args: [cli, ...args] };
|
|
134
184
|
}
|
|
135
185
|
|
|
136
|
-
|
|
186
|
+
// The copy's own lint base replaces any STARCI_BASE_REF of the caller, which names a ref of the source repository, not of the copy.
|
|
187
|
+
function commandEnvironment(lintBaseRef) {
|
|
137
188
|
const env = withoutGitLocalEnv(process.env);
|
|
138
|
-
|
|
189
|
+
const out = Object.fromEntries(Object.entries(env).filter(([name]) => !/^SOPS_AGE_KEY(?:_FILE)?$/iu.test(name)));
|
|
190
|
+
if (lintBaseRef) out.STARCI_BASE_REF = lintBaseRef;
|
|
191
|
+
return out;
|
|
139
192
|
}
|
|
140
193
|
|
|
141
194
|
/** Run an installed app command in a sealed temporary copy, returning its exit code and preserving report artifacts. */
|
|
142
195
|
async function runMaterializedCommand(repoRoot, command, args = [], { run = runProgram } = {}) {
|
|
143
|
-
return withMaterializedExample(repoRoot, '.', (app) => {
|
|
196
|
+
return withMaterializedExample(repoRoot, '.', (app, lintBaseRef) => {
|
|
144
197
|
const launch = commandLaunch(command, args);
|
|
145
|
-
const result = run(launch.file, launch.args, { cwd: app, env: commandEnvironment(), stdio: 'inherit', windowsHide: true });
|
|
198
|
+
const result = run(launch.file, launch.args, { cwd: app, env: commandEnvironment(lintBaseRef), stdio: 'inherit', windowsHide: true });
|
|
146
199
|
const reports = path.join(app, 'reports');
|
|
147
200
|
if (fs.existsSync(reports)) fs.cpSync(reports, path.join(repoRoot, 'reports'), { recursive: true });
|
|
148
201
|
if (result.error) throw new Error('the example check command could not start');
|
|
@@ -80,9 +80,9 @@ function hasAgeIdentity(job, doc) {
|
|
|
80
80
|
|
|
81
81
|
/** The release candidate branches (OWNER RULING candidate-ci, 2026-10-18): `starci app release push` waits for the ci.yml run of the release sha there. */
|
|
82
82
|
const CANDIDATE_BRANCHES = 'release/candidate-*';
|
|
83
|
-
const pushFilterValid = (options) => record(options) && Object.keys(options).length > 0 && Object.keys(options).every((key) => key === 'tags' || key === 'branches')
|
|
83
|
+
const pushFilterValid = (options, ci) => record(options) && Object.keys(options).length > 0 && Object.keys(options).every((key) => key === 'tags' || key === 'branches')
|
|
84
84
|
&& (options.tags === undefined || (Array.isArray(options.tags) && options.tags.length === 1 && options.tags[0] === 'v*'))
|
|
85
|
-
&& (options.branches === undefined || (Array.isArray(options.branches) && options.branches.length ===
|
|
85
|
+
&& (options.branches === undefined || (Array.isArray(options.branches) && options.branches.length > 0 && new Set(options.branches).size === options.branches.length && options.branches.every((branch) => branch === CANDIDATE_BRANCHES || (ci && branch === 'main'))));
|
|
86
86
|
|
|
87
87
|
function triggerFindings(doc, refuse, file = '') {
|
|
88
88
|
const on = workflowEventOptions(doc.on);
|
|
@@ -94,11 +94,11 @@ function triggerFindings(doc, refuse, file = '') {
|
|
|
94
94
|
for (const [event, options] of Object.entries(on)) {
|
|
95
95
|
if (event === 'workflow_dispatch') continue;
|
|
96
96
|
if (event !== 'push') {
|
|
97
|
-
findings.push(refuse(TRIGGER, `trigger ${event} is forbidden; app CI starts only on release tags [v*], the release candidate branches or workflow_dispatch`));
|
|
97
|
+
findings.push(refuse(TRIGGER, `trigger ${event} is forbidden; app CI starts only on release tags [v*], the release candidate branches, main in ci.yml, or workflow_dispatch`));
|
|
98
98
|
continue;
|
|
99
99
|
}
|
|
100
|
-
if (!pushFilterValid(options)) {
|
|
101
|
-
findings.push(refuse(TRIGGER, `push may contain only tags: [v*] and branches: ['${CANDIDATE_BRANCHES}']; other branch and path filters are forbidden`));
|
|
100
|
+
if (!pushFilterValid(options, /(^|\/)ci\.ya?ml$/.test(file))) {
|
|
101
|
+
findings.push(refuse(TRIGGER, `push may contain only tags: [v*] and branches: ['${CANDIDATE_BRANCHES}']; ci.yml may also name main; other branch and path filters are forbidden`));
|
|
102
102
|
}
|
|
103
103
|
}
|
|
104
104
|
return findings;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// CI_TRIGGERS_RELEASE_ONLY: runtime workflows and app ci.yml may judge main; release candidates, v* tags and manual dispatch retain their scope.
|
|
2
|
+
import { parseWorkflow, workflowFindings } from './workflow-source.mjs';
|
|
3
|
+
import { workflowEventOptions } from '../../lib/workflow-events.mjs';
|
|
4
|
+
|
|
5
|
+
export const CODE = 'CI_TRIGGERS_RELEASE_ONLY';
|
|
6
|
+
const RELEASE_TAGS = 'v*';
|
|
7
|
+
const RUNTIME_BRANCH = 'main';
|
|
8
|
+
/** The branch pattern of the release candidate refs (refs/heads/release/candidate-<version>), shared with the release flows. */
|
|
9
|
+
export const CANDIDATE_BRANCHES = 'release/candidate-*';
|
|
10
|
+
/** The workflows that judge an app release (every app ci.yml): they must run on the candidate refs. The runtime's own workflows judge each push to main instead. */
|
|
11
|
+
const RELEASE_WORKFLOW = /(^|\/)ci\.ya?ml$/;
|
|
12
|
+
|
|
13
|
+
const isReleaseTagFilter = (tags) => Array.isArray(tags) && tags.length === 1 && String(tags[0]) === RELEASE_TAGS;
|
|
14
|
+
/** The branches a filter may name: main (runtime workflows and app ci.yml) and the candidate pattern. */
|
|
15
|
+
const allowedBranches = (ownBranch) => (ownBranch ? [RUNTIME_BRANCH, CANDIDATE_BRANCHES] : [CANDIDATE_BRANCHES]);
|
|
16
|
+
const isBranchFilter = (ownBranch, branches) => Array.isArray(branches) && branches.length > 0 && new Set(branches.map(String)).size === branches.length
|
|
17
|
+
&& branches.every((branch) => allowedBranches(ownBranch).includes(String(branch)));
|
|
18
|
+
const namesCandidate = (branches) => Array.isArray(branches) && branches.map(String).includes(CANDIDATE_BRANCHES);
|
|
19
|
+
|
|
20
|
+
const missingFilterMessage = (ownBranch) => (ownBranch
|
|
21
|
+
? `\`push\` must be filtered to \`tags: ['${RELEASE_TAGS}']\` and/or \`branches\` of [${RUNTIME_BRANCH}, '${CANDIDATE_BRANCHES}'] (no other branch starts CI)`
|
|
22
|
+
: `\`push\` must be filtered to \`tags: ['${RELEASE_TAGS}']\` and/or \`branches: ['${CANDIDATE_BRANCHES}']\` (no other branch starts an app's CI)`);
|
|
23
|
+
|
|
24
|
+
const branchFilterMessage = (ownBranch) => (ownBranch
|
|
25
|
+
? `\`push.branches\` may name only ${RUNTIME_BRANCH} and '${CANDIDATE_BRANCHES}', each once`
|
|
26
|
+
: `\`push.branches\` may name only '${CANDIDATE_BRANCHES}' in an example or an app template: its CI runs on the release candidate and the release tag`);
|
|
27
|
+
|
|
28
|
+
function pushFindings({ ownBranch, config, refuse, release }) {
|
|
29
|
+
const filters = config && typeof config === 'object' && !Array.isArray(config) ? config : {};
|
|
30
|
+
const { tags, branches } = filters;
|
|
31
|
+
const found = [];
|
|
32
|
+
if (tags === undefined && branches === undefined) found.push(refuse(missingFilterMessage(ownBranch)));
|
|
33
|
+
if (tags !== undefined && !isReleaseTagFilter(tags)) found.push(refuse(`\`push.tags\` must be exactly ['${RELEASE_TAGS}']`));
|
|
34
|
+
if (branches !== undefined && !isBranchFilter(ownBranch, branches)) found.push(refuse(branchFilterMessage(ownBranch)));
|
|
35
|
+
if (release && !namesCandidate(branches)) found.push(refuse(`\`push.branches\` must name '${CANDIDATE_BRANCHES}': starci app release push waits for the CI run of the release sha on its candidate ref`));
|
|
36
|
+
for (const other of Object.keys(filters).filter((key) => key !== 'tags' && key !== 'branches')) found.push(refuse(`\`push.${other}\` is not allowed beside the trigger filters`));
|
|
37
|
+
return found;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function eventFindings({ event, config, ownBranch, refuse, release }) {
|
|
41
|
+
if (event === 'workflow_dispatch') return [];
|
|
42
|
+
if (event !== 'push') return [refuse(`the trigger \`${event}\` is not allowed: only a push of release tags [${RELEASE_TAGS}], of '${CANDIDATE_BRANCHES}' (and, in runtime workflows or app ci.yml, of branch ${RUNTIME_BRANCH}) and workflow_dispatch may start CI`)];
|
|
43
|
+
return pushFindings({ ownBranch, config, refuse, release });
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The CI_TRIGGERS_RELEASE_ONLY findings of one workflow text; a text that is not YAML yields none (the other checks name it). Runtime workflows and app ci.yml may also push on main. */
|
|
47
|
+
function workflowTriggerFindings({ path: file, text }) {
|
|
48
|
+
const doc = parseWorkflow(text);
|
|
49
|
+
if (!doc || typeof doc !== 'object') return [];
|
|
50
|
+
const ownBranch = file.startsWith('.github/workflows/') || RELEASE_WORKFLOW.test(file);
|
|
51
|
+
const refuse = (why) => ({ code: CODE, level: 'error', path: file, message: `${CODE} ${file}: ${why}` });
|
|
52
|
+
const on = doc.on;
|
|
53
|
+
if (on === undefined || on === null) return [refuse('the workflow has no `on` trigger block: it must declare push of tags [v*] and/or workflow_dispatch')];
|
|
54
|
+
const events = workflowEventOptions(on, String);
|
|
55
|
+
const release = !file.startsWith('.github/workflows/') && RELEASE_WORKFLOW.test(file);
|
|
56
|
+
const missingPush = release && !Object.hasOwn(events, 'push') ? [refuse(`the app release workflow must run on a push of '${CANDIDATE_BRANCHES}' and of tags [${RELEASE_TAGS}]`)] : [];
|
|
57
|
+
return [...missingPush, ...Object.entries(events).flatMap(([event, config]) => eventFindings({ event, config, ownBranch, refuse, release }))];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** CI_TRIGGERS_RELEASE_ONLY over every tracked workflow (ctx of scripts/hfs/runtime-check.mjs). */
|
|
61
|
+
export const gitTriggerFindings = (ctx) => workflowFindings(ctx, workflowTriggerFindings);
|
|
@@ -1,9 +1,7 @@
|
|
|
1
1
|
// hook-shape.mjs - RT_HOOK_SHAPE (knowledge/hfs/rules.yaml, gate runtime): the app hook templates keep the gate model.
|
|
2
2
|
// pre-commit L0 only: no typecheck and no test run (`test:` scripts, npm test, jest, vitest, a spec). Types and the targeted specs
|
|
3
3
|
// belong to the op gate.
|
|
4
|
-
// pre-push
|
|
5
|
-
// release gate markers: `starci-release` (the L4 record directory), `refs/backup/` (the backup namespace) and `v[0-9]`
|
|
6
|
-
// (the release tag pattern). A push of anything but the release cut and refs/backup/* is refused there.
|
|
4
|
+
// pre-push the bundled push gate owns fast-forward main, annotated tags and the secret scan; no build, test or lint run.
|
|
7
5
|
// Guard hooks keep their host timeout above the runtime's own input, verdict, cleanup and output budgets.
|
|
8
6
|
// Comments are not judged (the templates explain what they do not run). Pure apart from ctx.read.
|
|
9
7
|
import { shellCommandSource } from '../../lib/shell-source.mjs';
|
|
@@ -25,7 +23,7 @@ const PRE_PUSH_FORBIDDEN = [
|
|
|
25
23
|
[/\btypecheck\b/, 'a typecheck'],
|
|
26
24
|
[/\blint\b/, 'a lint run'],
|
|
27
25
|
];
|
|
28
|
-
const PRE_PUSH_MARKERS = [
|
|
26
|
+
const PRE_PUSH_MARKERS = ["node --input-type=module -e", "import { main } from '@starci/hfs/runtime/scripts/guards/release-push-gate.mjs'", 'process.exit(await main())'];
|
|
29
27
|
|
|
30
28
|
const finding = (file, message) => ({ code: CODE, level: 'error', path: file, message: `${CODE} ${file}: ${message}` });
|
|
31
29
|
|
|
@@ -35,13 +33,13 @@ function fileHookShapeFindings({ path: file, name, text }) {
|
|
|
35
33
|
const found = [];
|
|
36
34
|
const forbidden = name === 'pre-commit' ? PRE_COMMIT_FORBIDDEN : PRE_PUSH_FORBIDDEN;
|
|
37
35
|
for (const [pattern, what] of forbidden) {
|
|
38
|
-
if (pattern.test(body)) found.push(finding(file, name === 'pre-commit' ? `the commit gate is L0 only and runs ${what}; types and targeted specs belong to the op gate` : `the push gate
|
|
36
|
+
if (pattern.test(body)) found.push(finding(file, name === 'pre-commit' ? `the commit gate is L0 only and runs ${what}; types and targeted specs belong to the op gate` : `the push gate runs ${what}; it must use the bundled push policy alone`));
|
|
39
37
|
}
|
|
40
38
|
if (name === 'pre-push') {
|
|
41
39
|
const missing = PRE_PUSH_MARKERS.filter((marker) => !body.includes(marker));
|
|
42
40
|
if (missing.length) {
|
|
43
41
|
const markers = missing.map((m) => `\`${m}\``).join(', ');
|
|
44
|
-
found.push(finding(file, `the push gate lacks the
|
|
42
|
+
found.push(finding(file, `the push gate lacks the bundled gate marker${missing.length === 1 ? '' : 's'} ${markers}; it must invoke the shared fast-forward, annotation and secret-scan policy`));
|
|
45
43
|
}
|
|
46
44
|
}
|
|
47
45
|
return found;
|
|
@@ -9,6 +9,11 @@ import { byCodeUnit } from '../lib/list.mjs';
|
|
|
9
9
|
// builds afresh.
|
|
10
10
|
const runs = new AsyncLocalStorage();
|
|
11
11
|
|
|
12
|
+
/** Share compiler inputs only until this check finishes; nested callers join the same run. */
|
|
13
|
+
export function withTypeScriptProgramRun(run) {
|
|
14
|
+
return runs.getStore() ? run() : runs.run(new Map(), run);
|
|
15
|
+
}
|
|
16
|
+
|
|
12
17
|
|
|
13
18
|
function keyOf(value) {
|
|
14
19
|
if (value === undefined) return 'u';
|
|
@@ -48,7 +53,28 @@ export function sharedInProgramRun(kind, ts, input, build) {
|
|
|
48
53
|
/** ts.createProgram, shared inside a program run. */
|
|
49
54
|
export function createTypeScriptProgram(ts, { rootNames, options, projectReferences }) {
|
|
50
55
|
return sharedInProgramRun('program', ts, { rootNames, options, projectReferences },
|
|
51
|
-
() => ts.createProgram({ rootNames, options, projectReferences }));
|
|
56
|
+
() => ts.createProgram({ rootNames, options, projectReferences, host: sharedSourceHost(ts, options) }));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function sharedSourceHost(ts, options) {
|
|
60
|
+
if (!runs.getStore()) return undefined;
|
|
61
|
+
const registry = sharedInProgramRun('documents', ts, null,
|
|
62
|
+
() => ts.createDocumentRegistry(ts.sys.useCaseSensitiveFileNames, ts.sys.getCurrentDirectory()));
|
|
63
|
+
const host = ts.createCompilerHost(options);
|
|
64
|
+
const readSource = host.getSourceFile;
|
|
65
|
+
// TypeScript's registry separates parse-affecting settings and Node module formats. Sharing syntax trees leaves
|
|
66
|
+
// module resolution and symbol/type caches with each program, including projects with different path aliases.
|
|
67
|
+
host.getSourceFile = (file, sourceOptions, onError, fresh) => {
|
|
68
|
+
if (fresh) return readSource(file, sourceOptions, onError, fresh);
|
|
69
|
+
let text;
|
|
70
|
+
try { text = host.readFile(file); } catch (error) {
|
|
71
|
+
onError?.(String(error.message ?? error));
|
|
72
|
+
text = '';
|
|
73
|
+
}
|
|
74
|
+
if (text === undefined) return undefined;
|
|
75
|
+
return registry.acquireDocument(file, options, ts.ScriptSnapshot.fromString(text), '0', undefined, sourceOptions);
|
|
76
|
+
};
|
|
77
|
+
return host;
|
|
52
78
|
}
|
|
53
79
|
|
|
54
80
|
/** Read and expand a tsconfig with the caller's TypeScript compiler and optional tracked file reader. */
|
|
@@ -66,6 +92,7 @@ export function typeScriptProjectReferencePath(ts, reference) {
|
|
|
66
92
|
}
|
|
67
93
|
|
|
68
94
|
/** Resolve an import using the selected project's compiler options. */
|
|
69
|
-
export function resolveTypeScriptModule(ts, specifier, from, options, host
|
|
70
|
-
|
|
95
|
+
export function resolveTypeScriptModule(ts, specifier, from, options, host, cache) {
|
|
96
|
+
const resolverHost = host === undefined ? ts.sys : host;
|
|
97
|
+
return ts.resolveModuleName(specifier, from, options, resolverHost, cache).resolvedModule?.resolvedFileName;
|
|
71
98
|
}
|
|
@@ -49,7 +49,7 @@ export const STARTUP_RESERVATION_MS = 180_000;
|
|
|
49
49
|
|
|
50
50
|
export const DEFAULTS = Object.freeze({
|
|
51
51
|
workers: Object.freeze({ base: 0, max: 0, hardMax: 10 }),
|
|
52
|
-
landGate: Object.freeze({ mode: 'shared' }),
|
|
52
|
+
landGate: Object.freeze({ mode: 'shared', push: true }),
|
|
53
53
|
frozenMinutes: 10,
|
|
54
54
|
pollIntervalMs: 600_000,
|
|
55
55
|
});
|
|
@@ -194,7 +194,7 @@ export function supervisorSettings({ config = undefined } = {}) {
|
|
|
194
194
|
pollIntervalMs: Number.isInteger(sup.pollIntervalMs) ? sup.pollIntervalMs : DEFAULTS.pollIntervalMs,
|
|
195
195
|
workers: { base: Number.isInteger(sup.workers?.base) ? sup.workers.base : DEFAULTS.workers.base,
|
|
196
196
|
max: Math.min(DEFAULTS.workers.hardMax, Number.isInteger(sup.workers?.max) ? sup.workers.max : DEFAULTS.workers.max) },
|
|
197
|
-
landGate: { mode: sup.landGate?.mode === 'exclusive' ? 'exclusive' : DEFAULTS.landGate.mode },
|
|
197
|
+
landGate: { mode: sup.landGate?.mode === 'exclusive' ? 'exclusive' : DEFAULTS.landGate.mode, push: typeof sup.landGate?.push === 'boolean' ? sup.landGate.push : DEFAULTS.landGate.push },
|
|
198
198
|
// supervisor-watchdog.mjs: a busy seat frame with no turn progress for this long is frozen, not busy.
|
|
199
199
|
frozenMinutes: Number.isInteger(sup.frozenMinutes) && sup.frozenMinutes > 0 ? sup.frozenMinutes : DEFAULTS.frozenMinutes,
|
|
200
200
|
language: typeof cfg?.language === 'string' ? cfg.language : DEFAULT_OWNER_LANGUAGE,
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{{header}}
|
|
2
2
|
name: ci
|
|
3
3
|
|
|
4
|
-
#
|
|
4
|
+
# CI judges main, release candidates and release tags.
|
|
5
5
|
on:
|
|
6
6
|
push:
|
|
7
|
-
branches: ["release/candidate-*"]
|
|
7
|
+
branches: ["main", "release/candidate-*"]
|
|
8
8
|
tags: ["v*"]
|
|
9
9
|
workflow_dispatch:
|
|
10
10
|
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{{header}}
|
|
2
2
|
name: ci
|
|
3
3
|
|
|
4
|
-
#
|
|
4
|
+
# CI judges main, release candidates and release tags.
|
|
5
5
|
on:
|
|
6
6
|
push:
|
|
7
|
-
branches: ["release/candidate-*"]
|
|
7
|
+
branches: ["main", "release/candidate-*"]
|
|
8
8
|
tags: ["v*"]
|
|
9
9
|
workflow_dispatch:
|
|
10
10
|
|
|
@@ -1,57 +1,4 @@
|
|
|
1
1
|
{{header}}
|
|
2
|
-
# Push gate:
|
|
3
|
-
#
|
|
4
|
-
|
|
5
|
-
# refs/heads/main, refs/tags/v* allowed only when HEAD carries an annotated tag v[0-9]* and the release cut recorded the
|
|
6
|
-
# full suite of HEAD: <git common dir>/starci-release/<HEAD sha>.l4.json names that head and tag;
|
|
7
|
-
# a pushed tag must be that tag and must point at HEAD
|
|
8
|
-
# refs/heads/release/candidate-* the release candidate (starci app release push sends the release sha there first and moves main
|
|
9
|
-
# and the tag only when its ci.yml run is green): allowed under the same release gate, never deleted
|
|
10
|
-
# any other ref, a delete refused
|
|
11
|
-
common=$(git rev-parse --git-common-dir)
|
|
12
|
-
case "$common" in /* | [A-Za-z]:*) ;; *) common="$(pwd)/$common" ;; esac
|
|
13
|
-
head=$(git rev-parse HEAD)
|
|
14
|
-
record="$common/starci-release/$head.l4.json"
|
|
15
|
-
status=0
|
|
16
|
-
gate=""
|
|
17
|
-
gate_tag=""
|
|
18
|
-
refuse() {
|
|
19
|
-
echo "RIGHTS_PUSH_NOT_RELEASE: $1 - the allowed path is the release cut (main, v* tags and the release/candidate-* ref) or refs/backup/* (backups)" >&2
|
|
20
|
-
status=1
|
|
21
|
-
}
|
|
22
|
-
# gate: the verdict of the release gate for HEAD, computed once: "ok" or the reason it is closed.
|
|
23
|
-
release_gate() {
|
|
24
|
-
if [ -n "$gate" ]; then return 0; fi
|
|
25
|
-
gate="no annotated tag v[0-9]* on HEAD $head"
|
|
26
|
-
tags=$(git tag --points-at HEAD --list 'v[0-9]*' 2>/dev/null) || { gate="cannot list the tags of HEAD $head"; return 0; }
|
|
27
|
-
for tag in $tags; do
|
|
28
|
-
kind=$(git cat-file -t "refs/tags/$tag" 2>/dev/null) || continue
|
|
29
|
-
if [ "$kind" != "tag" ]; then continue; fi
|
|
30
|
-
if [ ! -f "$record" ]; then gate="no L4 record $record"; continue; fi
|
|
31
|
-
flat=$(tr -d ' \t\r\n' < "$record")
|
|
32
|
-
case "$flat" in
|
|
33
|
-
*'"head":"'"$head"'"'*'"tag":"'"$tag"'"'* | *'"tag":"'"$tag"'"'*'"head":"'"$head"'"'*) gate="ok"; gate_tag="$tag"; return 0 ;;
|
|
34
|
-
esac
|
|
35
|
-
gate="the L4 record $record does not name head $head and tag $tag"
|
|
36
|
-
done
|
|
37
|
-
return 0
|
|
38
|
-
}
|
|
39
|
-
while read -r local_ref local_sha remote_ref remote_sha; do
|
|
40
|
-
case "$remote_ref" in
|
|
41
|
-
refs/backup/*) ;;
|
|
42
|
-
refs/heads/main | refs/tags/v* | refs/heads/release/candidate-*)
|
|
43
|
-
case "$local_sha" in
|
|
44
|
-
*[!0]*) ;;
|
|
45
|
-
*) refuse "refusing to delete $remote_ref"; continue ;;
|
|
46
|
-
esac
|
|
47
|
-
release_gate
|
|
48
|
-
if [ "$gate" != "ok" ]; then refuse "$remote_ref is closed: $gate"; continue; fi
|
|
49
|
-
case "$remote_ref" in
|
|
50
|
-
refs/tags/*)
|
|
51
|
-
if [ "$remote_ref" != "refs/tags/$gate_tag" ]; then refuse "$remote_ref is not the release tag $gate_tag of HEAD $head"
|
|
52
|
-
elif [ "$(git rev-parse "$local_sha^{commit}" 2>/dev/null)" != "$head" ]; then refuse "$remote_ref does not point at HEAD $head"; fi ;;
|
|
53
|
-
esac ;;
|
|
54
|
-
*) refuse "refusing to push $remote_ref" ;;
|
|
55
|
-
esac
|
|
56
|
-
done
|
|
57
|
-
exit "$status"
|
|
2
|
+
# Push gate: fast-forward main with a clean secret scan, annotated v* tags, and bounded ref input.
|
|
3
|
+
# The bundled gate owns the policy; backups and lane refs use the same policy as the runtime.
|
|
4
|
+
node --input-type=module -e "import { main } from '@starci/hfs/runtime/scripts/guards/release-push-gate.mjs'; process.exit(await main());"
|
|
@@ -13,9 +13,7 @@ export interface ClientOperation {
|
|
|
13
13
|
}
|
|
14
14
|
|
|
15
15
|
/** The domain work whose JSON result commits with the client receipt. */
|
|
16
|
-
export
|
|
17
|
-
(): Promise<T>
|
|
18
|
-
}
|
|
16
|
+
export type ClientOperationEffect<T> = () => Promise<T>
|
|
19
17
|
|
|
20
18
|
/** The signed provider header may omit its delivery identity; the adapter refuses that delivery. */
|
|
21
19
|
export type InboxEventId = string | undefined
|
|
@@ -27,9 +27,7 @@ export interface DeliveryClaimBackfill extends DeliveryClaim {
|
|
|
27
27
|
}
|
|
28
28
|
|
|
29
29
|
/** The domain work whose JSON result commits with the client receipt. */
|
|
30
|
-
export
|
|
31
|
-
(): Promise<T>
|
|
32
|
-
}
|
|
30
|
+
export type ClientOperationEffect<T> = () => Promise<T>
|
|
33
31
|
|
|
34
32
|
/** Deduplicates deliveries by the source and event id of their consumer. */
|
|
35
33
|
export interface Inbox {
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { EnvSource } from "@modules/platform/config"
|
|
2
|
+
import { REDIS_DEFAULT_PREFIX } from "./realtime.contracts"
|
|
3
|
+
import type { PostgresRealtimeConfig, RealtimeConfig, RedisRealtimeOptions } from "./realtime.options"
|
|
4
|
+
|
|
5
|
+
/** The hubs an app can run. */
|
|
6
|
+
const HUBS = ["memory", "postgres", "redis"] as const
|
|
7
|
+
|
|
8
|
+
/** The Redis hub: its URL (a secret), database and the prefix of its channel and keys. */
|
|
9
|
+
const redisConfigOf = (env: EnvSource): RedisRealtimeOptions => ({
|
|
10
|
+
hub: "redis",
|
|
11
|
+
url: env.secret("REALTIME_REDIS_URL"),
|
|
12
|
+
db: env.int("REALTIME_REDIS_DB", 0),
|
|
13
|
+
prefix: env.optional("REALTIME_REDIS_PREFIX") ?? REDIS_DEFAULT_PREFIX,
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
/** The Postgres hub: the dedicated LISTEN URL when `REALTIME_DB_URL` is declared. */
|
|
17
|
+
const postgresConfigOf = (env: EnvSource): PostgresRealtimeConfig => ({
|
|
18
|
+
hub: "postgres",
|
|
19
|
+
listenUrl: env.has("REALTIME_DB_URL") ? env.secret("REALTIME_DB_URL") : undefined,
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Reads the hub from `REALTIME_HUB`; when it is not declared the full edition defaults to `redis` (the in-process `memory` hub is
|
|
24
|
+
* never a default, so a scaled deploy cannot fall back to it unseen). For the Redis hub it reads the address and prefix. A Postgres
|
|
25
|
+
* hub LISTENs on `REALTIME_DB_URL` when it is declared (a direct or session-mode URL, never a transaction pooler), else on the
|
|
26
|
+
* primary database URL; either way the boot probe refuses a connection that does not deliver notifications.
|
|
27
|
+
*/
|
|
28
|
+
export const parseRealtimeConfig = (env: EnvSource): RealtimeConfig => {
|
|
29
|
+
const hub = env.has("REALTIME_HUB") ? env.enum("REALTIME_HUB", HUBS) : "redis"
|
|
30
|
+
if (hub === "redis") return redisConfigOf(env)
|
|
31
|
+
return hub === "postgres" ? postgresConfigOf(env) : { hub }
|
|
32
|
+
}
|
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
// write-json-file.mjs - one JSON document written to a file (the runtime's pretty-printed state files).
|
|
2
|
-
import fs from 'node:fs';
|
|
3
|
-
import path from 'node:path';
|
|
4
|
-
|
|
5
|
-
/** `value` written to `file` as two-space JSON plus a trailing newline (parent directories created). */
|
|
6
|
-
export function writeJsonFile(file, value) {
|
|
7
|
-
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
8
|
-
fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}
|
|
9
|
-
`);
|
|
10
|
-
}
|
|
@@ -1,5 +0,0 @@
|
|
|
1
|
-
// merge-base-query.mjs — `git merge-base <args>`: one merge-base query (the best common ancestor, or --is-ancestor's exit status).
|
|
2
|
-
import { runGit } from './lib.mjs';
|
|
3
|
-
|
|
4
|
-
/** The spawn result {status, stdout, stderr, error} of `git merge-base ...args`; options are runGit's (cwd or dir, config, timeout, maxBuffer, input, encoding, env, git). */
|
|
5
|
-
export const mergeBaseQuery = (args = [], options = {}) => runGit(['merge-base', ...args], options);
|