@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.
Files changed (47) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/lint/run.mjs +46 -10
  3. package/package.json +4 -1
  4. package/release/candidate-ci.mjs +7 -7
  5. package/release/push.mjs +3 -3
  6. package/runtime/config.example.yaml +9 -10
  7. package/runtime/engine/config.mjs +3 -3
  8. package/runtime/engine/release-config.mjs +5 -5
  9. package/runtime/knowledge/grammars/offset-pop/playbook.yaml +1 -1
  10. package/runtime/knowledge/grammars/starci/playbook.yaml +27 -3
  11. package/runtime/knowledge/hfs/canon-pins.yaml +14 -7
  12. package/runtime/knowledge/hfs/ci-standard.yaml +1 -1
  13. package/runtime/knowledge/hfs/fe-stack.yaml +12 -1
  14. package/runtime/knowledge/hfs/rules.yaml +41 -6
  15. package/runtime/knowledge/hfs/slots.yaml +5 -5
  16. package/runtime/knowledge/patterns/be/realtime.yaml +72 -12
  17. package/runtime/modules/kernel/command-policy.yaml +5 -5
  18. package/runtime/modules/kernel/failure-codes.yaml +42 -20
  19. package/runtime/modules/kernel/removed-vocabulary.yaml +33 -7
  20. package/runtime/packages/cli/src/catalog.generated.mjs +90 -37
  21. package/runtime/scripts/api/git/commit-tree.mjs +5 -0
  22. package/runtime/scripts/api/git/update-ref.mjs +8 -0
  23. package/runtime/scripts/api/git/write-tree.mjs +5 -0
  24. package/runtime/scripts/guards/command-policy.mjs +4 -4
  25. package/runtime/scripts/guards/push-scan.mjs +128 -0
  26. package/runtime/scripts/guards/release-push-gate.mjs +31 -29
  27. package/runtime/scripts/hfs/architecture/index.mjs +2 -1
  28. package/runtime/scripts/hfs/architecture/type-context.mjs +38 -23
  29. package/runtime/scripts/hfs/example-secrets.mjs +61 -8
  30. package/runtime/scripts/hfs/rules/deploy-workflow.mjs +5 -5
  31. package/runtime/scripts/hfs/runtime-rules/git-triggers.mjs +61 -0
  32. package/runtime/scripts/hfs/runtime-rules/hook-shape.mjs +4 -6
  33. package/runtime/scripts/hfs/typescript-programs.mjs +30 -3
  34. package/runtime/scripts/machine/home.mjs +2 -2
  35. package/templates/app/ci-workflows/github/workflows/ci.yml +2 -2
  36. package/templates/app/ci-workflows-lite/github/workflows/ci.yml +2 -2
  37. package/templates/app/hooks/husky/pre-push +3 -56
  38. package/templates/be/inbox/platform/inbox/inbox.port.ts.tpl +1 -3
  39. package/templates/be/patterns/idempotency/platform/inbox/inbox.port.ts.tpl +1 -3
  40. package/templates/be/patterns/realtime/realtime-config.mapper.ts.tpl +32 -0
  41. package/runtime/scripts/api/fs/write-json-file.mjs +0 -10
  42. package/runtime/scripts/api/git/merge-base-query.mjs +0 -5
  43. package/runtime/scripts/gates/commit-proof.mjs +0 -53
  44. package/runtime/scripts/guards/release-definition.mjs +0 -204
  45. package/runtime/scripts/guards/release-record.mjs +0 -58
  46. package/runtime/scripts/guards/release-suite-mode.mjs +0 -15
  47. 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 occurrences = new Map();
108
- const sourceIn = new Map();
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().filter(file => helpers.isProductionSource(config.root, file))) {
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
- if (!occurrences.has(name)) occurrences.set(name, []);
113
- occurrences.get(name).push(project);
114
- sourceIn.set(`${project.relative}|${name}`, sourceFile);
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 { occurrences, sourceIn };
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 { occurrences, sourceIn } = collectProjectFiles(config, projects, helpers);
127
- const fileMap = new Map();
132
+ const { fileMap, ownerByFile } = collectProjectFiles(config, projects, helpers);
128
133
  const checkerByFile = new Map();
129
- const ownerByFile = new Map();
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 => fileGraph.checkerByFile.get(helpers.canonical(file)) ?? null,
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
- function trackExample(repoRoot) {
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
- function commandEnvironment() {
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
- return Object.fromEntries(Object.entries(env).filter(([name]) => !/^SOPS_AGE_KEY(?:_FILE)?$/iu.test(name)));
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 === 1 && options.branches[0] === CANDIDATE_BRANCHES));
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 a release gate check, never a build or test run: no npm test, jest, vitest, typecheck or lint, and it carries the
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 = ['starci-release', 'refs/backup/', 'v[0-9]', 'RIGHTS_PUSH_NOT_RELEASE'];
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 checks the release gate and runs ${what}; only the release cut tests`));
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 release gate marker${missing.length === 1 ? '' : 's'} ${markers}; it must check the L4 record of HEAD and allow only the release cut and refs/backup/*`));
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 = ts.sys) {
70
- return ts.resolveModuleName(specifier, from, options, host).resolvedModule?.resolvedFileName;
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
- # The release candidate ref first (starci app release push waits for this run of the release sha before the branch and the tag move), then the tag.
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
- # The release candidate ref first (starci app release push waits for this run of the release sha before the branch and the tag move), then the tag.
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: a release gate check, never a test run. Nothing but the release cut pushes main or a release tag, and the coordinator
3
- # backs work up under refs/backup/*. Git hands this hook one line per ref: <local ref> <local sha> <remote ref> <remote sha>.
4
- # refs/backup/* allowed: the coordinator backup namespace
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 interface ClientOperationEffect<T> {
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 interface ClientOperationEffect<T> {
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);