@starci/hfs 4.7.12 → 4.7.14
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 +11 -0
- package/README.md +1 -1
- package/package.json +1 -1
- package/release/gate.mjs +388 -378
- package/release/lint-report.mjs +52 -0
- package/runtime/knowledge/hfs/canon-pins.yaml +4 -4
- package/runtime/knowledge/hfs/ci-standard.yaml +1 -1
- package/runtime/knowledge/hfs/rules.yaml +7 -6
- package/runtime/knowledge/patterns/be/cli.yaml +1 -0
- package/runtime/knowledge/patterns/be/realtime.yaml +1 -0
- package/runtime/knowledge/patterns/be/webhooks.yaml +1 -0
- package/runtime/modules/kernel/failure-codes.yaml +24 -2
- package/runtime/packages/cli/src/catalog.generated.mjs +12 -6
- package/runtime/scripts/hfs/architecture/canon-clones.mjs +1 -1
- package/runtime/scripts/hfs/architecture/hfs.mjs +19 -4
- package/runtime/scripts/hfs/architecture/i18n-keys.mjs +6 -4
- package/runtime/scripts/hfs/architecture/transport-owner.mjs +1 -1
- package/runtime/scripts/hfs/ci-declaration.mjs +5 -2
- package/runtime/scripts/hfs/db-baseline-verb.mjs +5 -2
- package/runtime/scripts/hfs/doc-commands.mjs +2 -1
- package/runtime/scripts/hfs/rule-catalog.mjs +5 -2
- package/runtime/scripts/hfs/rules/coverage-below.mjs +5 -3
- package/runtime/scripts/hfs/rules/database-access.mjs +1 -1
- package/runtime/scripts/hfs/rules/database-migrations.mjs +13 -3
- package/runtime/scripts/hfs/rules/database-plpgsql.mjs +1 -1
- package/runtime/scripts/hfs/rules/database-queues.mjs +2 -2
- package/runtime/scripts/hfs/rules/deploy-scan.mjs +50 -6
- package/runtime/scripts/hfs/rules/deploy-workflow.mjs +11 -1
- package/runtime/scripts/hfs/rules/doc-fresh.mjs +18 -3
- package/runtime/scripts/hfs/rules/docker-health.mjs +4 -1
- package/runtime/scripts/hfs/rules/env-parity.mjs +8 -7
- package/runtime/scripts/hfs/rules/env-value-shape.mjs +2 -4
- package/runtime/scripts/hfs/rules/fe-build-args.mjs +1 -1
- package/runtime/scripts/hfs/rules/release-provenance.mjs +8 -5
- package/runtime/scripts/hfs/rules/script-hygiene.mjs +23 -7
- package/runtime/scripts/hfs/rules/services.mjs +19 -5
- package/runtime/scripts/hfs/rules/stack-tooling.mjs +5 -2
- package/runtime/scripts/hfs/rules/stacks-swarm.mjs +5 -5
- package/runtime/scripts/hfs/rules/staged-specs.mjs +26 -0
- package/runtime/scripts/hfs/rules/supply-chain.mjs +3 -3
- package/runtime/scripts/hfs/rules/unused-symbol-properties.mjs +1 -1
- package/runtime/scripts/hfs/rules/unused-symbol.mjs +8 -4
- package/runtime/scripts/hfs/sealed-members.mjs +1 -1
- package/runtime/scripts/hfs/secret-bootstrap-crypto.mjs +1 -1
- package/runtime/scripts/hfs/secret-files.mjs +1 -1
- package/runtime/scripts/hfs/secret-sops.mjs +1 -1
- package/runtime/scripts/hfs/stack-deploy-config.mjs +19 -2
- package/runtime/scripts/lib/env.mjs +2 -1
- package/runtime/scripts/lib/git-hooks-dir.mjs +30 -0
- package/runtime/scripts/lib/path-key.mjs +1 -1
- package/runtime/scripts/lib/roots.mjs +3 -3
- package/runtime/scripts/lib/secret-patterns.mjs +1 -1
- package/runtime/scripts/lib/swarm-stack.mjs +4 -2
- package/src/main.mjs +2 -2
- package/sync/ci-commands.mjs +275 -275
- package/sync/hygiene.mjs +19 -4
- package/templates/app/ci-workflows-lite/github/workflows/ci.yml +2 -0
package/sync/ci-commands.mjs
CHANGED
|
@@ -1,275 +1,275 @@
|
|
|
1
|
-
import fs from 'node:fs';
|
|
2
|
-
import { parseYaml } from '../runtime/engine/yaml.mjs';
|
|
3
|
-
import { refusal } from '../stack/errors.mjs';
|
|
4
|
-
import { byCodeUnit } from '../report/order.mjs';
|
|
5
|
-
|
|
6
|
-
// One command vocabulary for managed CI rendering and the runtime coverage gate.
|
|
7
|
-
const exampleCommand = (source, command) => source?.runtimeExample
|
|
8
|
-
? 'node node_modules/@starci/hfs/runtime/scripts/hfs/example-secrets.mjs --run . -- ' + command
|
|
9
|
-
: command;
|
|
10
|
-
|
|
11
|
-
const UPLOAD_ARTIFACT = 'actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2';
|
|
12
|
-
const DOWNLOAD_ARTIFACT = 'actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0';
|
|
13
|
-
const SONAR_CONDITION = "${{ !cancelled() && startsWith(github.ref, 'refs/tags/v') && env.SONAR_TOKEN != '' }}";
|
|
14
|
-
|
|
15
|
-
/** Optional uploads require reachable upload and authentication endpoints without exposing identity tokens. */
|
|
16
|
-
function codecovProbe(codecov, condition) {
|
|
17
|
-
if (!codecov.optional) return [];
|
|
18
|
-
return [
|
|
19
|
-
' - name: Codecov availability',
|
|
20
|
-
' id: codecov-availability',
|
|
21
|
-
` if: ${condition}`,
|
|
22
|
-
' env:',
|
|
23
|
-
` CODECOV_AUTH: ${codecov.auth}`,
|
|
24
|
-
' run: |',
|
|
25
|
-
" node --input-type=module <<'NODE'",
|
|
26
|
-
" import fs from 'node:fs';",
|
|
27
|
-
' let available = false;',
|
|
28
|
-
' try {',
|
|
29
|
-
" const upload = await fetch('https://ingest.codecov.io/upload/v4', { signal: AbortSignal.timeout(10000) });",
|
|
30
|
-
' available = upload.status < 500;',
|
|
31
|
-
" if (available && process.env.CODECOV_AUTH === 'oidc') {",
|
|
32
|
-
" const url = new URL(process.env.ACTIONS_ID_TOKEN_REQUEST_URL);",
|
|
33
|
-
" url.searchParams.set('audience', 'https://codecov.io');",
|
|
34
|
-
" const identity = await fetch(url, { headers: { Authorization: 'Bearer ' + process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN }, signal: AbortSignal.timeout(10000) });",
|
|
35
|
-
' available = identity.ok && Boolean((await identity.json()).value);',
|
|
36
|
-
' }',
|
|
37
|
-
' } catch { available = false; }',
|
|
38
|
-
" fs.appendFileSync(process.env.GITHUB_OUTPUT, 'up=' + available + '\\n');",
|
|
39
|
-
" if (!available) console.log('::notice::codecov upload skipped: upload endpoint or OIDC token unavailable (services.codecov.optional)');",
|
|
40
|
-
' NODE',
|
|
41
|
-
];
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
/** Optional Sonar only runs analysis when the provider reports UP within ten seconds. */
|
|
45
|
-
function sonarProbe(sonar) {
|
|
46
|
-
if (!sonar.optional) return [];
|
|
47
|
-
return [
|
|
48
|
-
' - name: Sonar availability',
|
|
49
|
-
' id: sonar-availability',
|
|
50
|
-
` if: ${SONAR_CONDITION}`,
|
|
51
|
-
' run: |',
|
|
52
|
-
` if curl -fsS --max-time 10 "$SONAR_HOST_URL/api/system/status" | node -e "let s='';process.stdin.on('data',c=>s+=c);process.stdin.on('end',()=>{try{process.exit(JSON.parse(s).status==='UP'?0:1)}catch{process.exit(1)}})"; then`,
|
|
53
|
-
' echo "up=true" >> "$GITHUB_OUTPUT"',
|
|
54
|
-
' else',
|
|
55
|
-
' echo "up=false" >> "$GITHUB_OUTPUT"',
|
|
56
|
-
' echo "::notice::sonar skipped: $SONAR_HOST_URL not reachable (services.sonar.optional)"',
|
|
57
|
-
' fi',
|
|
58
|
-
];
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
/** Codecov reports missing coverage without failing twice; uploads retain the release-tag condition and required authentication. */
|
|
62
|
-
function codecovSteps(lcovReport, codecov) {
|
|
63
|
-
if (codecov.enabled === false || codecov.wiring === 'not-used') return [
|
|
64
|
-
' - name: coverage upload',
|
|
65
|
-
' run: echo "codecov disabled in .starcistacks/application-stacks.yaml"',
|
|
66
|
-
].join('\n');
|
|
67
|
-
const condition = `\${{ !cancelled() && startsWith(github.ref, 'refs/tags/v') && hashFiles('${lcovReport}') != '' }}`;
|
|
68
|
-
const uploadCondition = codecov.optional ? condition.replace(' }}', " && steps.codecov-availability.outputs.up == 'true' }}") : condition;
|
|
69
|
-
return [
|
|
70
|
-
' - name: coverage upload skipped',
|
|
71
|
-
` if: \${{ !cancelled() && hashFiles('${lcovReport}') == '' }}`,
|
|
72
|
-
' run: |',
|
|
73
|
-
` echo "::notice::codecov upload skipped: no coverage file at ${lcovReport} (the test step failed or produced none)"`,
|
|
74
|
-
' exit 0',
|
|
75
|
-
...codecovProbe(codecov, condition),
|
|
76
|
-
' - name: coverage upload',
|
|
77
|
-
...(codecov.optional ? [' id: codecov-upload', ' continue-on-error: true'] : []),
|
|
78
|
-
' uses: codecov/codecov-action@0fb7174895f61a3b6b78fc075e0cd60383518dac # v5.5.5',
|
|
79
|
-
` if: ${uploadCondition}`,
|
|
80
|
-
' with:',
|
|
81
|
-
codecov.auth === 'token' ? ` token: \${{ secrets.${codecov.token} }}` : ' use_oidc: true',
|
|
82
|
-
` files: ${lcovReport}`,
|
|
83
|
-
' disable_search: true',
|
|
84
|
-
` fail_ci_if_error: ${!codecov.optional}`,
|
|
85
|
-
...(codecov.optional ? [
|
|
86
|
-
' - name: Codecov upload unavailable',
|
|
87
|
-
" if: ${{ !cancelled() && steps.codecov-upload.outcome == 'failure' }}",
|
|
88
|
-
' run: |',
|
|
89
|
-
' echo "::notice::codecov upload skipped: upload unavailable (services.codecov.optional)"',
|
|
90
|
-
] : []),
|
|
91
|
-
].join('\n');
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
/** Sonar consumes the test coverage and fast lint artifacts only on release tags with a configured token. */
|
|
95
|
-
function sonarJob(sonar, lcovReport, lcovArtifact, nodeMajor, timeout) {
|
|
96
|
-
if (!sonar) return '';
|
|
97
|
-
const condition = sonar.optional ? SONAR_CONDITION.replace(' }}', " && steps.sonar-availability.outputs.up == 'true' }}") : SONAR_CONDITION;
|
|
98
|
-
const host = sonar.provider === 'sonarcloud' ? 'https://sonarcloud.io' : `\${{ vars.${sonar.hostVar} }}`;
|
|
99
|
-
const header = [
|
|
100
|
-
' sonar:',
|
|
101
|
-
' needs: [lint, coverage]',
|
|
102
|
-
` timeout-minutes: ${timeout}`,
|
|
103
|
-
' runs-on: ubuntu-latest',
|
|
104
|
-
' env:',
|
|
105
|
-
` SONAR_TOKEN: \${{ secrets.${sonar.token} }}`,
|
|
106
|
-
` SONAR_HOST_URL: ${host}`,
|
|
107
|
-
' steps:',
|
|
108
|
-
].join('\n');
|
|
109
|
-
const steps = [
|
|
110
|
-
...sonarProbe(sonar),
|
|
111
|
-
` - uses: ${DOWNLOAD_ARTIFACT}`,
|
|
112
|
-
` if: ${condition}`,
|
|
113
|
-
' with:',
|
|
114
|
-
` name: ${lcovArtifact}`,
|
|
115
|
-
` path: ${lcovReport.split('/').slice(0, -1).join('/') || '.'}`,
|
|
116
|
-
` - uses: ${DOWNLOAD_ARTIFACT}`,
|
|
117
|
-
` if: ${condition}`,
|
|
118
|
-
' with:',
|
|
119
|
-
' pattern: sonar-lint-*',
|
|
120
|
-
' path: reports',
|
|
121
|
-
' merge-multiple: true',
|
|
122
|
-
...sonarMergeSteps(condition),
|
|
123
|
-
' - uses: SonarSource/sonarqube-scan-action@c7ee0f9df90b7aa20e8dcf9695dcfe2e7da5b4f2 # v7.2.1',
|
|
124
|
-
` if: ${condition}`,
|
|
125
|
-
' - uses: SonarSource/sonarqube-quality-gate-action@7a5fffe8e523c40e0c740b6bc2712ab503e52efa # v1.2.1',
|
|
126
|
-
` if: ${condition}`,
|
|
127
|
-
' timeout-minutes: 10',
|
|
128
|
-
].join('\n');
|
|
129
|
-
// Variable strings are inserted after render() expands partials, so resolve Sonar's shared install partial before insertion.
|
|
130
|
-
const read = name => fs.readFileSync(new URL('../templates/app/ci-workflows/' + name, import.meta.url), 'utf8').replaceAll('\r\n', '\n');
|
|
131
|
-
return read('sonar-steps.yml')
|
|
132
|
-
.replace('{{> app/ci-workflows/install-steps.yml}}', () => read('install-steps.yml').trimEnd())
|
|
133
|
-
.replaceAll('{{nodeMajor}}', nodeMajor)
|
|
134
|
-
.replace('{{sonarJobHeader}}', () => header)
|
|
135
|
-
.replace('{{sonarJobSteps}}', () => steps)
|
|
136
|
-
.trimEnd() + '\n';
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
/** The app source reader is be-relative; the authored manifest lives one directory above that side. */
|
|
140
|
-
function buildDeclaration(source) {
|
|
141
|
-
if (!source?.read) return {};
|
|
142
|
-
try {
|
|
143
|
-
return parseYaml(source.read('../.starcistacks/application-stacks.yaml')) ?? {};
|
|
144
|
-
} catch (error) {
|
|
145
|
-
if (error.code === 'ENOENT') return {};
|
|
146
|
-
throw error;
|
|
147
|
-
}
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
/** Static CI values are strings with valid environment names; expressions cannot turn public config into runtime secret reads. */
|
|
151
|
-
function mergeBuildValues(values, entries) {
|
|
152
|
-
for (const [key, value] of Object.entries(entries ?? {})) {
|
|
153
|
-
if (!/^[A-Z][A-Z0-9_]*$/u.test(key) || typeof value !== 'string' || value.includes('${{')) {
|
|
154
|
-
throw refusal('STACK_BUILD_ARG_INVALID', 'CI build values must map environment names to literal strings');
|
|
155
|
-
}
|
|
156
|
-
if (values.has(key) && values.get(key) !== value) {
|
|
157
|
-
throw refusal('STACK_BUILD_ARG_INVALID', 'CI build values disagree for ' + key);
|
|
158
|
-
}
|
|
159
|
-
values.set(key, value);
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
/** Image values own their keys; the build-fe gate supplies only missing keys, and sealed members stay unopened. */
|
|
164
|
-
function imageBuildEnv(image, gates) {
|
|
165
|
-
const values = new Map();
|
|
166
|
-
mergeBuildValues(values, image?.buildArgs?.values);
|
|
167
|
-
const fallback = new Map();
|
|
168
|
-
for (const gate of gates.filter(entry => entry.name === 'build-fe')) mergeBuildValues(fallback, gate.env);
|
|
169
|
-
for (const [key, value] of fallback) if (!values.has(key)) values.set(key, value);
|
|
170
|
-
if (!values.size) return '';
|
|
171
|
-
return [' env:', ...[...values.keys()].sort(byCodeUnit).map(key => ' ' + key + ': ' + JSON.stringify(values.get(key)))].join('\n');
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
/** Multiple deployed front ends build separately through the installed turbo (a run step has no node_modules/.bin on PATH), so each fe/apps/<app> and its dependencies see its own public values. */
|
|
175
|
-
function frontendBuild(source, app) {
|
|
176
|
-
const declaration = buildDeclaration(source);
|
|
177
|
-
const environments = Object.keys(source?.stack?.imageEnvironments ?? {});
|
|
178
|
-
const environment = environments.length > 1 ? 'production' : environments[0];
|
|
179
|
-
const images = Object.values(declaration.environments?.[environment]?.deploy?.images ?? {});
|
|
180
|
-
const frontend = images.filter(image => app?.sides.fe.apps.some(entry => entry.name === image.app));
|
|
181
|
-
const gates = declaration.release?.gates ?? [];
|
|
182
|
-
const singleEnv = imageBuildEnv(frontend[0], gates);
|
|
183
|
-
if (frontend.length <= 1) return { env: singleEnv, steps: [' - name: build fe', ' run: npm run build:fe', singleEnv].filter(Boolean).join('\n') };
|
|
184
|
-
const steps = frontend.map(image => [
|
|
185
|
-
' - name: build fe ' + image.app,
|
|
186
|
-
' run: npm run codegen --silent && npx --no-install turbo run build --filter=./fe/apps/' + image.app,
|
|
187
|
-
imageBuildEnv(image, gates),
|
|
188
|
-
].filter(Boolean).join('\n')).join('\n');
|
|
189
|
-
return { env: singleEnv, steps };
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
/** The per-side lint reports (one artifact per lint matrix side) join into the one reports/lint.sonar.json the Sonar import reads, even when one side wrote none. */
|
|
193
|
-
function sonarMergeSteps(condition) {
|
|
194
|
-
return [
|
|
195
|
-
' - name: merge Sonar lint reports',
|
|
196
|
-
` if: ${condition}`,
|
|
197
|
-
' run: |',
|
|
198
|
-
" node --input-type=module <<'NODE'",
|
|
199
|
-
" import fs from 'node:fs';",
|
|
200
|
-
" const files = ['reports/lint.be.sonar.json', 'reports/lint.fe.sonar.json'];",
|
|
201
|
-
" const reports = files.filter(file => fs.existsSync(file)).map(file => JSON.parse(fs.readFileSync(file, 'utf8')));",
|
|
202
|
-
' const rules = [...new Map(reports.flatMap(report => report.rules).map(rule => [rule.id, rule])).values()];',
|
|
203
|
-
' const issues = reports.flatMap(report => report.issues);',
|
|
204
|
-
" fs.mkdirSync('reports', { recursive: true });",
|
|
205
|
-
" fs.writeFileSync('reports/lint.sonar.json', JSON.stringify({ rules, issues }, null, 2) + '\\n');",
|
|
206
|
-
' NODE',
|
|
207
|
-
];
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
const STANDARD = new URL('../runtime/knowledge/hfs/ci-standard.yaml', import.meta.url);
|
|
211
|
-
/** The per-job settings of the managed CI: ci.jobs.<job>.timeoutMinutes and ci.jobs.test.shards of the manifest over the defaults of the CI standard. */
|
|
212
|
-
function ciJobSettings(declaration) {
|
|
213
|
-
const { defaults, heap } = parseYaml(fs.readFileSync(STANDARD, 'utf8'));
|
|
214
|
-
const jobs = declaration?.ci?.jobs ?? {};
|
|
215
|
-
const timeout = (job) => String(jobs[job]?.timeoutMinutes ?? defaults.timeoutMinutes[job]);
|
|
216
|
-
const shards = Number(jobs.test?.shards ?? defaults.shards);
|
|
217
|
-
return {
|
|
218
|
-
nodeOptions: `--max-old-space-size=${heap.minMiB}`,
|
|
219
|
-
timeoutFast: timeout('fast'), timeoutLint: timeout('lint'), timeoutTest: timeout('test'), timeoutCoverage: timeout('coverage'),
|
|
220
|
-
timeoutBuild: timeout('build'), timeoutSonar: timeout('sonar'), timeoutDbTypes: timeout('db-types'),
|
|
221
|
-
testShards: `[${Array.from({ length: shards }, (_, index) => index + 1).join(', ')}]`, testShardTotal: String(shards),
|
|
222
|
-
};
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
/** An omitted quality declaration preserves hosted Sonar, required OIDC Codecov, and the high production dependency audit. */
|
|
226
|
-
export function ciWorkflowCommands(source, lcovReport = 'be/coverage/lcov.info', quality = undefined, { nodeMajor = '22', app } = {}) {
|
|
227
|
-
const configuredSonar = quality === undefined ? { token: 'SONAR_TOKEN', hostVar: 'SONAR_HOST_URL' } : quality.sonar;
|
|
228
|
-
const sonar = configuredSonar?.wiring === 'not-used' || configuredSonar?.mode === 'disabled' ? null : configuredSonar;
|
|
229
|
-
const codecov = quality?.codecov ?? { enabled: true, auth: 'oidc' };
|
|
230
|
-
const audit = quality?.audit ?? { level: 'high', omitDev: true };
|
|
231
|
-
const lcovArtifact = 'coverage-lcov';
|
|
232
|
-
const build = frontendBuild(source, app);
|
|
233
|
-
const settings = ciJobSettings(buildDeclaration(source));
|
|
234
|
-
const side = '${{ matrix.side }}';
|
|
235
|
-
return {
|
|
236
|
-
...settings,
|
|
237
|
-
lcovShardsDir: lcovReport.split('/').slice(0, -1).concat('shards').join('/'),
|
|
238
|
-
// The istanbul JSON a shard writes beside the lcov (STARCI_COVERAGE_SHARD=1): what the coverage job merges.
|
|
239
|
-
shardCoverageReport: lcovReport.split('/').slice(0, -1).concat('coverage-final.json').join('/'),
|
|
240
|
-
ciBuildEnv: build.env,
|
|
241
|
-
ciBuildSteps: build.steps,
|
|
242
|
-
ciLint: exampleCommand(source, 'npm run lint'),
|
|
243
|
-
ciLintSonar: exampleCommand(source, 'npm run lint -- --sonar reports/lint.sonar.json'),
|
|
244
|
-
ciCoverageProof: 'npm run codegen --silent && ' + exampleCommand(source, 'npx starci app check --coverage-from ' + lcovReport),
|
|
245
|
-
ciDatabaseLint: exampleCommand(source, 'npm run db:lint'),
|
|
246
|
-
auditCommand: `npm audit${audit.omitDev ? ' --omit=dev' : ''} --audit-level=${audit.level}`,
|
|
247
|
-
sonarTokenSecret: sonar?.token ?? 'SONAR_TOKEN',
|
|
248
|
-
sonarHostVar: sonar?.hostVar ?? 'SONAR_HOST_URL',
|
|
249
|
-
sonarEnv: sonar ? ` env:\n SONAR_TOKEN: \${{ secrets.${sonar.token} }}` : '',
|
|
250
|
-
// One lint job per side (the lint matrix): with a Sonar token each side writes its own report, uploaded as sonar-lint-<side> and merged by the sonar job.
|
|
251
|
-
ciLintSteps: [
|
|
252
|
-
' - name: lint',
|
|
253
|
-
...(sonar ? [" if: env.SONAR_TOKEN == ''"] : []),
|
|
254
|
-
` run: ${exampleCommand(source, 'npm run lint -- --side ' + side)}`,
|
|
255
|
-
' env:',
|
|
256
|
-
` NODE_OPTIONS: ${settings.nodeOptions}`,
|
|
257
|
-
...(sonar ? [
|
|
258
|
-
' - name: lint with Sonar import',
|
|
259
|
-
" if: env.SONAR_TOKEN != ''",
|
|
260
|
-
` run: ${exampleCommand(source, 'npm run lint -- --side ' + side + ' --sonar reports/lint.' + side + '.sonar.json')}`,
|
|
261
|
-
' env:',
|
|
262
|
-
` NODE_OPTIONS: ${settings.nodeOptions}`,
|
|
263
|
-
` - uses: ${UPLOAD_ARTIFACT}`,
|
|
264
|
-
// Inside an if: expression the matrix value is an argument of format(), never a nested ${{ }} (actionlint: a nested expression makes the condition always true).
|
|
265
|
-
" if: always() && env.SONAR_TOKEN != '' && hashFiles(format('reports/lint.{0}.sonar.json', matrix.side)) != ''",
|
|
266
|
-
' with:',
|
|
267
|
-
` name: sonar-lint-${side}`,
|
|
268
|
-
` path: reports/lint.${side}.sonar.json`,
|
|
269
|
-
] : []),
|
|
270
|
-
].join('\n'),
|
|
271
|
-
codecovSteps: codecovSteps(lcovReport, codecov),
|
|
272
|
-
sonarJob: sonarJob(sonar, lcovReport, lcovArtifact, nodeMajor, settings.timeoutSonar),
|
|
273
|
-
lcovArtifact,
|
|
274
|
-
};
|
|
275
|
-
}
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import { parseYaml } from '../runtime/engine/yaml.mjs';
|
|
3
|
+
import { refusal } from '../stack/errors.mjs';
|
|
4
|
+
import { byCodeUnit } from '../report/order.mjs';
|
|
5
|
+
|
|
6
|
+
// One command vocabulary for managed CI rendering and the runtime coverage gate.
|
|
7
|
+
const exampleCommand = (source, command) => source?.runtimeExample
|
|
8
|
+
? 'node node_modules/@starci/hfs/runtime/scripts/hfs/example-secrets.mjs --run . -- ' + command
|
|
9
|
+
: command;
|
|
10
|
+
|
|
11
|
+
const UPLOAD_ARTIFACT = 'actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2';
|
|
12
|
+
const DOWNLOAD_ARTIFACT = 'actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0';
|
|
13
|
+
const SONAR_CONDITION = "${{ !cancelled() && startsWith(github.ref, 'refs/tags/v') && env.SONAR_TOKEN != '' }}";
|
|
14
|
+
|
|
15
|
+
/** Optional uploads require reachable upload and authentication endpoints without exposing identity tokens. */
|
|
16
|
+
function codecovProbe(codecov, condition) {
|
|
17
|
+
if (!codecov.optional) return [];
|
|
18
|
+
return [
|
|
19
|
+
' - name: Codecov availability',
|
|
20
|
+
' id: codecov-availability',
|
|
21
|
+
` if: ${condition}`,
|
|
22
|
+
' env:',
|
|
23
|
+
` CODECOV_AUTH: ${codecov.auth}`,
|
|
24
|
+
' run: |',
|
|
25
|
+
" node --input-type=module <<'NODE'",
|
|
26
|
+
" import fs from 'node:fs';",
|
|
27
|
+
' let available = false;',
|
|
28
|
+
' try {',
|
|
29
|
+
" const upload = await fetch('https://ingest.codecov.io/upload/v4', { signal: AbortSignal.timeout(10000) });",
|
|
30
|
+
' available = upload.status < 500;',
|
|
31
|
+
" if (available && process.env.CODECOV_AUTH === 'oidc') {",
|
|
32
|
+
" const url = new URL(process.env.ACTIONS_ID_TOKEN_REQUEST_URL);",
|
|
33
|
+
" url.searchParams.set('audience', 'https://codecov.io');",
|
|
34
|
+
" const identity = await fetch(url, { headers: { Authorization: 'Bearer ' + process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN }, signal: AbortSignal.timeout(10000) });",
|
|
35
|
+
' available = identity.ok && Boolean((await identity.json()).value);',
|
|
36
|
+
' }',
|
|
37
|
+
' } catch { available = false; }',
|
|
38
|
+
" fs.appendFileSync(process.env.GITHUB_OUTPUT, 'up=' + available + '\\n');",
|
|
39
|
+
" if (!available) console.log('::notice::codecov upload skipped: upload endpoint or OIDC token unavailable (services.codecov.optional)');",
|
|
40
|
+
' NODE',
|
|
41
|
+
];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Optional Sonar only runs analysis when the provider reports UP within ten seconds. */
|
|
45
|
+
function sonarProbe(sonar) {
|
|
46
|
+
if (!sonar.optional) return [];
|
|
47
|
+
return [
|
|
48
|
+
' - name: Sonar availability',
|
|
49
|
+
' id: sonar-availability',
|
|
50
|
+
` if: ${SONAR_CONDITION}`,
|
|
51
|
+
' run: |',
|
|
52
|
+
` if curl -fsS --max-time 10 "$SONAR_HOST_URL/api/system/status" | node -e "let s='';process.stdin.on('data',c=>s+=c);process.stdin.on('end',()=>{try{process.exit(JSON.parse(s).status==='UP'?0:1)}catch{process.exit(1)}})"; then`,
|
|
53
|
+
' echo "up=true" >> "$GITHUB_OUTPUT"',
|
|
54
|
+
' else',
|
|
55
|
+
' echo "up=false" >> "$GITHUB_OUTPUT"',
|
|
56
|
+
' echo "::notice::sonar skipped: $SONAR_HOST_URL not reachable (services.sonar.optional)"',
|
|
57
|
+
' fi',
|
|
58
|
+
];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Codecov reports missing coverage without failing twice; uploads retain the release-tag condition and required authentication. */
|
|
62
|
+
function codecovSteps(lcovReport, codecov) {
|
|
63
|
+
if (codecov.enabled === false || codecov.wiring === 'not-used') return [
|
|
64
|
+
' - name: coverage upload',
|
|
65
|
+
' run: echo "codecov disabled in .starcistacks/application-stacks.yaml"',
|
|
66
|
+
].join('\n');
|
|
67
|
+
const condition = `\${{ !cancelled() && startsWith(github.ref, 'refs/tags/v') && hashFiles('${lcovReport}') != '' }}`;
|
|
68
|
+
const uploadCondition = codecov.optional ? condition.replace(' }}', " && steps.codecov-availability.outputs.up == 'true' }}") : condition;
|
|
69
|
+
return [
|
|
70
|
+
' - name: coverage upload skipped',
|
|
71
|
+
` if: \${{ !cancelled() && hashFiles('${lcovReport}') == '' }}`,
|
|
72
|
+
' run: |',
|
|
73
|
+
` echo "::notice::codecov upload skipped: no coverage file at ${lcovReport} (the test step failed or produced none)"`,
|
|
74
|
+
' exit 0',
|
|
75
|
+
...codecovProbe(codecov, condition),
|
|
76
|
+
' - name: coverage upload',
|
|
77
|
+
...(codecov.optional ? [' id: codecov-upload', ' continue-on-error: true'] : []),
|
|
78
|
+
' uses: codecov/codecov-action@0fb7174895f61a3b6b78fc075e0cd60383518dac # v5.5.5',
|
|
79
|
+
` if: ${uploadCondition}`,
|
|
80
|
+
' with:',
|
|
81
|
+
codecov.auth === 'token' ? ` token: \${{ secrets.${codecov.token} }}` : ' use_oidc: true',
|
|
82
|
+
` files: ${lcovReport}`,
|
|
83
|
+
' disable_search: true',
|
|
84
|
+
` fail_ci_if_error: ${!codecov.optional}`,
|
|
85
|
+
...(codecov.optional ? [
|
|
86
|
+
' - name: Codecov upload unavailable',
|
|
87
|
+
" if: ${{ !cancelled() && steps.codecov-upload.outcome == 'failure' }}",
|
|
88
|
+
' run: |',
|
|
89
|
+
' echo "::notice::codecov upload skipped: upload unavailable (services.codecov.optional)"',
|
|
90
|
+
] : []),
|
|
91
|
+
].join('\n');
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Sonar consumes the test coverage and fast lint artifacts only on release tags with a configured token. */
|
|
95
|
+
function sonarJob(sonar, lcovReport, lcovArtifact, nodeMajor, timeout) {
|
|
96
|
+
if (!sonar) return '';
|
|
97
|
+
const condition = sonar.optional ? SONAR_CONDITION.replace(' }}', " && steps.sonar-availability.outputs.up == 'true' }}") : SONAR_CONDITION;
|
|
98
|
+
const host = sonar.provider === 'sonarcloud' ? 'https://sonarcloud.io' : `\${{ vars.${sonar.hostVar} }}`;
|
|
99
|
+
const header = [
|
|
100
|
+
' sonar:',
|
|
101
|
+
' needs: [lint, coverage]',
|
|
102
|
+
` timeout-minutes: ${timeout}`,
|
|
103
|
+
' runs-on: ubuntu-latest',
|
|
104
|
+
' env:',
|
|
105
|
+
` SONAR_TOKEN: \${{ secrets.${sonar.token} }}`,
|
|
106
|
+
` SONAR_HOST_URL: ${host}`,
|
|
107
|
+
' steps:',
|
|
108
|
+
].join('\n');
|
|
109
|
+
const steps = [
|
|
110
|
+
...sonarProbe(sonar),
|
|
111
|
+
` - uses: ${DOWNLOAD_ARTIFACT}`,
|
|
112
|
+
` if: ${condition}`,
|
|
113
|
+
' with:',
|
|
114
|
+
` name: ${lcovArtifact}`,
|
|
115
|
+
` path: ${lcovReport.split('/').slice(0, -1).join('/') || '.'}`,
|
|
116
|
+
` - uses: ${DOWNLOAD_ARTIFACT}`,
|
|
117
|
+
` if: ${condition}`,
|
|
118
|
+
' with:',
|
|
119
|
+
' pattern: sonar-lint-*',
|
|
120
|
+
' path: reports',
|
|
121
|
+
' merge-multiple: true',
|
|
122
|
+
...sonarMergeSteps(condition),
|
|
123
|
+
' - uses: SonarSource/sonarqube-scan-action@c7ee0f9df90b7aa20e8dcf9695dcfe2e7da5b4f2 # v7.2.1',
|
|
124
|
+
` if: ${condition}`,
|
|
125
|
+
' - uses: SonarSource/sonarqube-quality-gate-action@7a5fffe8e523c40e0c740b6bc2712ab503e52efa # v1.2.1',
|
|
126
|
+
` if: ${condition}`,
|
|
127
|
+
' timeout-minutes: 10',
|
|
128
|
+
].join('\n');
|
|
129
|
+
// Variable strings are inserted after render() expands partials, so resolve Sonar's shared install partial before insertion.
|
|
130
|
+
const read = name => fs.readFileSync(new URL('../templates/app/ci-workflows/' + name, import.meta.url), 'utf8').replaceAll('\r\n', '\n');
|
|
131
|
+
return read('sonar-steps.yml')
|
|
132
|
+
.replace('{{> app/ci-workflows/install-steps.yml}}', () => read('install-steps.yml').trimEnd())
|
|
133
|
+
.replaceAll('{{nodeMajor}}', nodeMajor)
|
|
134
|
+
.replace('{{sonarJobHeader}}', () => header)
|
|
135
|
+
.replace('{{sonarJobSteps}}', () => steps)
|
|
136
|
+
.trimEnd() + '\n';
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** The app source reader is be-relative; the authored manifest lives one directory above that side. */
|
|
140
|
+
function buildDeclaration(source) {
|
|
141
|
+
if (!source?.read) return {};
|
|
142
|
+
try {
|
|
143
|
+
return parseYaml(source.read('../.starcistacks/application-stacks.yaml')) ?? {};
|
|
144
|
+
} catch (error) {
|
|
145
|
+
if (error.code === 'ENOENT') return {};
|
|
146
|
+
throw error;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Static CI values are strings with valid environment names; expressions cannot turn public config into runtime secret reads. */
|
|
151
|
+
function mergeBuildValues(values, entries) {
|
|
152
|
+
for (const [key, value] of Object.entries(entries ?? {})) {
|
|
153
|
+
if (!/^[A-Z][A-Z0-9_]*$/u.test(key) || typeof value !== 'string' || value.includes('${{')) {
|
|
154
|
+
throw refusal('STACK_BUILD_ARG_INVALID', 'CI build values must map environment names to literal strings');
|
|
155
|
+
}
|
|
156
|
+
if (values.has(key) && values.get(key) !== value) {
|
|
157
|
+
throw refusal('STACK_BUILD_ARG_INVALID', 'CI build values disagree for ' + key);
|
|
158
|
+
}
|
|
159
|
+
values.set(key, value);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Image values own their keys; the build-fe gate supplies only missing keys, and sealed members stay unopened. */
|
|
164
|
+
function imageBuildEnv(image, gates) {
|
|
165
|
+
const values = new Map();
|
|
166
|
+
mergeBuildValues(values, image?.buildArgs?.values);
|
|
167
|
+
const fallback = new Map();
|
|
168
|
+
for (const gate of gates.filter(entry => entry.name === 'build-fe')) mergeBuildValues(fallback, gate.env);
|
|
169
|
+
for (const [key, value] of fallback) if (!values.has(key)) values.set(key, value);
|
|
170
|
+
if (!values.size) return '';
|
|
171
|
+
return [' env:', ...[...values.keys()].sort(byCodeUnit).map(key => ' ' + key + ': ' + JSON.stringify(values.get(key)))].join('\n');
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Multiple deployed front ends build separately through the installed turbo (a run step has no node_modules/.bin on PATH), so each fe/apps/<app> and its dependencies see its own public values. */
|
|
175
|
+
function frontendBuild(source, app) {
|
|
176
|
+
const declaration = buildDeclaration(source);
|
|
177
|
+
const environments = Object.keys(source?.stack?.imageEnvironments ?? {});
|
|
178
|
+
const environment = environments.length > 1 ? 'production' : environments[0];
|
|
179
|
+
const images = Object.values(declaration.environments?.[environment]?.deploy?.images ?? {});
|
|
180
|
+
const frontend = images.filter(image => app?.sides.fe.apps.some(entry => entry.name === image.app));
|
|
181
|
+
const gates = declaration.release?.gates ?? [];
|
|
182
|
+
const singleEnv = imageBuildEnv(frontend[0], gates);
|
|
183
|
+
if (frontend.length <= 1) return { env: singleEnv, steps: [' - name: build fe', ' run: npm run build:fe', singleEnv].filter(Boolean).join('\n') };
|
|
184
|
+
const steps = frontend.map(image => [
|
|
185
|
+
' - name: build fe ' + image.app,
|
|
186
|
+
' run: npm run codegen --silent && npx --no-install turbo run build --filter=./fe/apps/' + image.app,
|
|
187
|
+
imageBuildEnv(image, gates),
|
|
188
|
+
].filter(Boolean).join('\n')).join('\n');
|
|
189
|
+
return { env: singleEnv, steps };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** The per-side lint reports (one artifact per lint matrix side) join into the one reports/lint.sonar.json the Sonar import reads, even when one side wrote none. The local release gate writes the same file with mergeSonarReports (release/lint-report.mjs): keep both identical. */
|
|
193
|
+
function sonarMergeSteps(condition) {
|
|
194
|
+
return [
|
|
195
|
+
' - name: merge Sonar lint reports',
|
|
196
|
+
` if: ${condition}`,
|
|
197
|
+
' run: |',
|
|
198
|
+
" node --input-type=module <<'NODE'",
|
|
199
|
+
" import fs from 'node:fs';",
|
|
200
|
+
" const files = ['reports/lint.be.sonar.json', 'reports/lint.fe.sonar.json'];",
|
|
201
|
+
" const reports = files.filter(file => fs.existsSync(file)).map(file => JSON.parse(fs.readFileSync(file, 'utf8')));",
|
|
202
|
+
' const rules = [...new Map(reports.flatMap(report => report.rules).map(rule => [rule.id, rule])).values()];',
|
|
203
|
+
' const issues = reports.flatMap(report => report.issues);',
|
|
204
|
+
" fs.mkdirSync('reports', { recursive: true });",
|
|
205
|
+
" fs.writeFileSync('reports/lint.sonar.json', JSON.stringify({ rules, issues }, null, 2) + '\\n');",
|
|
206
|
+
' NODE',
|
|
207
|
+
];
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const STANDARD = new URL('../runtime/knowledge/hfs/ci-standard.yaml', import.meta.url);
|
|
211
|
+
/** The per-job settings of the managed CI: ci.jobs.<job>.timeoutMinutes and ci.jobs.test.shards of the manifest over the defaults of the CI standard. */
|
|
212
|
+
function ciJobSettings(declaration) {
|
|
213
|
+
const { defaults, heap } = parseYaml(fs.readFileSync(STANDARD, 'utf8'));
|
|
214
|
+
const jobs = declaration?.ci?.jobs ?? {};
|
|
215
|
+
const timeout = (job) => String(jobs[job]?.timeoutMinutes ?? defaults.timeoutMinutes[job]);
|
|
216
|
+
const shards = Number(jobs.test?.shards ?? defaults.shards);
|
|
217
|
+
return {
|
|
218
|
+
nodeOptions: `--max-old-space-size=${heap.minMiB}`,
|
|
219
|
+
timeoutFast: timeout('fast'), timeoutLint: timeout('lint'), timeoutTest: timeout('test'), timeoutCoverage: timeout('coverage'),
|
|
220
|
+
timeoutBuild: timeout('build'), timeoutSonar: timeout('sonar'), timeoutDbTypes: timeout('db-types'),
|
|
221
|
+
testShards: `[${Array.from({ length: shards }, (_, index) => index + 1).join(', ')}]`, testShardTotal: String(shards),
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** An omitted quality declaration preserves hosted Sonar, required OIDC Codecov, and the high production dependency audit. */
|
|
226
|
+
export function ciWorkflowCommands(source, lcovReport = 'be/coverage/lcov.info', quality = undefined, { nodeMajor = '22', app } = {}) {
|
|
227
|
+
const configuredSonar = quality === undefined ? { token: 'SONAR_TOKEN', hostVar: 'SONAR_HOST_URL' } : quality.sonar;
|
|
228
|
+
const sonar = configuredSonar?.wiring === 'not-used' || configuredSonar?.mode === 'disabled' ? null : configuredSonar;
|
|
229
|
+
const codecov = quality?.codecov ?? { enabled: true, auth: 'oidc' };
|
|
230
|
+
const audit = quality?.audit ?? { level: 'high', omitDev: true };
|
|
231
|
+
const lcovArtifact = 'coverage-lcov';
|
|
232
|
+
const build = frontendBuild(source, app);
|
|
233
|
+
const settings = ciJobSettings(buildDeclaration(source));
|
|
234
|
+
const side = '${{ matrix.side }}';
|
|
235
|
+
return {
|
|
236
|
+
...settings,
|
|
237
|
+
lcovShardsDir: lcovReport.split('/').slice(0, -1).concat('shards').join('/'),
|
|
238
|
+
// The istanbul JSON a shard writes beside the lcov (STARCI_COVERAGE_SHARD=1): what the coverage job merges.
|
|
239
|
+
shardCoverageReport: lcovReport.split('/').slice(0, -1).concat('coverage-final.json').join('/'),
|
|
240
|
+
ciBuildEnv: build.env,
|
|
241
|
+
ciBuildSteps: build.steps,
|
|
242
|
+
ciLint: exampleCommand(source, 'npm run lint'),
|
|
243
|
+
ciLintSonar: exampleCommand(source, 'npm run lint -- --sonar reports/lint.sonar.json'),
|
|
244
|
+
ciCoverageProof: 'npm run codegen --silent && ' + exampleCommand(source, 'npx starci app check --coverage-from ' + lcovReport),
|
|
245
|
+
ciDatabaseLint: exampleCommand(source, 'npm run db:lint'),
|
|
246
|
+
auditCommand: `npm audit${audit.omitDev ? ' --omit=dev' : ''} --audit-level=${audit.level}`,
|
|
247
|
+
sonarTokenSecret: sonar?.token ?? 'SONAR_TOKEN',
|
|
248
|
+
sonarHostVar: sonar?.hostVar ?? 'SONAR_HOST_URL',
|
|
249
|
+
sonarEnv: sonar ? ` env:\n SONAR_TOKEN: \${{ secrets.${sonar.token} }}` : '',
|
|
250
|
+
// One lint job per side (the lint matrix): with a Sonar token each side writes its own report, uploaded as sonar-lint-<side> and merged by the sonar job.
|
|
251
|
+
ciLintSteps: [
|
|
252
|
+
' - name: lint',
|
|
253
|
+
...(sonar ? [" if: env.SONAR_TOKEN == ''"] : []),
|
|
254
|
+
` run: ${exampleCommand(source, 'npm run lint -- --side ' + side)}`,
|
|
255
|
+
' env:',
|
|
256
|
+
` NODE_OPTIONS: ${settings.nodeOptions}`,
|
|
257
|
+
...(sonar ? [
|
|
258
|
+
' - name: lint with Sonar import',
|
|
259
|
+
" if: env.SONAR_TOKEN != ''",
|
|
260
|
+
` run: ${exampleCommand(source, 'npm run lint -- --side ' + side + ' --sonar reports/lint.' + side + '.sonar.json')}`,
|
|
261
|
+
' env:',
|
|
262
|
+
` NODE_OPTIONS: ${settings.nodeOptions}`,
|
|
263
|
+
` - uses: ${UPLOAD_ARTIFACT}`,
|
|
264
|
+
// Inside an if: expression the matrix value is an argument of format(), never a nested ${{ }} (actionlint: a nested expression makes the condition always true).
|
|
265
|
+
" if: always() && env.SONAR_TOKEN != '' && hashFiles(format('reports/lint.{0}.sonar.json', matrix.side)) != ''",
|
|
266
|
+
' with:',
|
|
267
|
+
` name: sonar-lint-${side}`,
|
|
268
|
+
` path: reports/lint.${side}.sonar.json`,
|
|
269
|
+
] : []),
|
|
270
|
+
].join('\n'),
|
|
271
|
+
codecovSteps: codecovSteps(lcovReport, codecov),
|
|
272
|
+
sonarJob: sonarJob(sonar, lcovReport, lcovArtifact, nodeMajor, settings.timeoutSonar),
|
|
273
|
+
lcovArtifact,
|
|
274
|
+
};
|
|
275
|
+
}
|
package/sync/hygiene.mjs
CHANGED
|
@@ -3,8 +3,10 @@
|
|
|
3
3
|
// .starcistacks must not be a plaintext secret (only *.enc is sealed). It is also the secrets guard of the commit: every staged file, in
|
|
4
4
|
// any tree, is read from the index and judged with the one secret judgement of `starci app check` (scripts/hfs/rules/secrets.mjs: a secret by
|
|
5
5
|
// being, an .enc that is no sops envelope, a line that matches a secret pattern), so no plaintext secret reaches the history whatever
|
|
6
|
-
// .gitignore says (`git add -f`, a path tracked before a rule tightened). There is no override.
|
|
7
|
-
//
|
|
6
|
+
// .gitignore says (`git add -f`, a path tracked before a rule tightened). There is no override. At an HFS app root (hfs.json) it also
|
|
7
|
+
// refuses a back-end logic file the index adds without its unit spec beside it (BE_UNIT_SPEC_NOT_STAGED,
|
|
8
|
+
// scripts/hfs/rules/staged-specs.mjs over the coverage scope), naming the file. The pre-commit hook judges the staged files;
|
|
9
|
+
// scripts/gates/hfs-sync.mjs judges every tracked file.
|
|
8
10
|
//
|
|
9
11
|
// Run inside a full runtime checkout — the repository under judgment is the checkout — it also reports the
|
|
10
12
|
// state-root ledger finding of that checkout's scripts/housekeeping/hk-orphan-ledgers.mjs: LEDGER_ORPHAN_STATE_ROOT. Any other repository — a product repo,
|
|
@@ -14,6 +16,8 @@ import fs from 'node:fs';
|
|
|
14
16
|
import path from 'node:path';
|
|
15
17
|
import { pathToFileURL } from 'node:url';
|
|
16
18
|
import { secretFileFindings } from '../runtime/scripts/hfs/rules/secrets.mjs';
|
|
19
|
+
import { stagedSpecFindings } from '../runtime/scripts/hfs/rules/staged-specs.mjs';
|
|
20
|
+
import { loadSlotManifest } from '../runtime/scripts/hfs/slots.mjs';
|
|
17
21
|
import { runGit } from '../runtime/scripts/api/git/lib.mjs';
|
|
18
22
|
|
|
19
23
|
// One git call through the runtime's git owner (scripts/api/git/lib.mjs): stdout, or the failure thrown the way execFileSync threw it (error.status carries the exit code). The caller's own env is passed on unchanged: a hook runs with GIT_INDEX_FILE set, which the staged reads must keep seeing.
|
|
@@ -65,6 +69,15 @@ export const stagedFiles = cwd => gitList(cwd, ['diff', '--cached', '--name-only
|
|
|
65
69
|
/** Every tracked file. */
|
|
66
70
|
export const trackedFiles = cwd => gitList(cwd, ['ls-files', '-z']);
|
|
67
71
|
|
|
72
|
+
/** Files the index adds for the next commit. */
|
|
73
|
+
const addedFiles = cwd => gitList(cwd, ['diff', '--cached', '--name-only', '--diff-filter=A', '-z']);
|
|
74
|
+
|
|
75
|
+
/** The new back-end logic files staged without their unit spec, at an HFS app root only (a repository without hfs.json has no slots). */
|
|
76
|
+
function missingSpecFindings(cwd, { added = addedFiles(cwd), indexed, manifest } = {}) {
|
|
77
|
+
if (!fs.existsSync(path.join(cwd, 'hfs.json')) || !added.some(file => file.startsWith('be/'))) return [];
|
|
78
|
+
return stagedSpecFindings(manifest ?? loadSlotManifest(), added, indexed ?? new Set(trackedFiles(cwd)));
|
|
79
|
+
}
|
|
80
|
+
|
|
68
81
|
const MAX_STAGED_BYTES = 1024 * 1024;
|
|
69
82
|
|
|
70
83
|
/** The text of `file` as the index holds it (what the commit would record), or null when it is absent, binary or over 1 MB. */
|
|
@@ -125,9 +138,11 @@ async function ledgerHygieneFindings(cwd = process.cwd()) {
|
|
|
125
138
|
}
|
|
126
139
|
}
|
|
127
140
|
|
|
128
|
-
/** `starci app hygiene`: checks staged files plus state-root ledger findings when reachable; returns the exit code. */
|
|
141
|
+
/** `starci app hygiene`: checks staged files (for the staged run also the new logic files without a spec) plus state-root ledger findings when reachable; returns the exit code. */
|
|
129
142
|
export async function runWorkHygiene({ cwd = process.cwd(), out = line => process.stdout.write(`${line}\n`), files } = {}) {
|
|
130
|
-
const
|
|
143
|
+
const judged = judge(cwd, files ?? stagedFiles(cwd));
|
|
144
|
+
const { checked } = judged;
|
|
145
|
+
const findings = [...judged.findings, ...(files ? [] : missingSpecFindings(cwd))];
|
|
131
146
|
const ledgerFindings = await ledgerHygieneFindings(cwd);
|
|
132
147
|
const all = [...findings, ...ledgerFindings];
|
|
133
148
|
for (const finding of all) out(`${finding.code} ${finding.file} ${finding.message}`);
|