@starci/hfs 4.7.26 → 4.7.28
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 +16 -0
- package/README.md +2 -3
- package/package.json +7 -1
- package/release/candidate-ci.mjs +3 -21
- package/runtime/engine/yaml.mjs +1 -1
- package/runtime/knowledge/hfs/canon-pins.yaml +4 -4
- package/runtime/knowledge/hfs/rules.yaml +913 -637
- package/runtime/knowledge/hfs/slots.yaml +21 -14
- package/runtime/modules/kernel/failure-codes.yaml +10 -0
- package/runtime/packages/cli/src/catalog.generated.mjs +130 -54
- package/runtime/scripts/api/git/check-ignore.mjs +5 -0
- package/runtime/scripts/gates/runtime-host.mjs +83 -0
- package/runtime/scripts/gates/sonar-gate.mjs +251 -0
- package/runtime/scripts/gates/starcistacks-ci.mjs +55 -0
- package/runtime/scripts/gates/starcistacks-form.mjs +26 -0
- package/runtime/scripts/gates/starcistacks-runtime.mjs +2 -0
- package/runtime/scripts/gates/starcistacks-value-file.mjs +17 -0
- package/runtime/scripts/gates/starcistacks.mjs +499 -0
- package/runtime/scripts/hfs/app-wiring.mjs +2 -2
- package/runtime/scripts/hfs/architecture/supabase.mjs +1 -1
- package/runtime/scripts/hfs/check.mjs +4 -1
- package/runtime/scripts/hfs/coverage-from.mjs +1 -1
- package/runtime/scripts/hfs/manifest-shape.mjs +1 -3
- package/runtime/scripts/hfs/rule-catalog.mjs +7 -10
- package/runtime/scripts/hfs/rules/a15-findings.mjs +1 -1
- package/runtime/scripts/hfs/rules/assets.mjs +1 -1
- package/runtime/scripts/hfs/rules/bypass-markers.mjs +24 -0
- package/runtime/scripts/hfs/rules/frontend-tree.mjs +2 -2
- package/runtime/scripts/hfs/rules/supabase-secrets.mjs +1 -1
- package/runtime/scripts/lib/bypass-markers.mjs +116 -0
- package/runtime/scripts/lib/check-code-loosening.mjs +128 -0
- package/runtime/scripts/lib/example-refs.mjs +205 -0
- package/runtime/scripts/lib/failure-code-reads.mjs +100 -0
- package/runtime/scripts/lib/gate-loosening.mjs +52 -8
- package/runtime/scripts/lib/json-formats.mjs +34 -0
- package/runtime/scripts/lib/managed-json.mjs +1 -1
- package/runtime/scripts/lib/package-at.mjs +1 -1
- package/runtime/scripts/lib/properties.mjs +16 -0
- package/runtime/scripts/lib/provision-control-failure.mjs +32 -0
- package/runtime/scripts/lib/sonar-new-code.mjs +58 -0
- package/runtime/scripts/lib/starciwork-boundary.mjs +164 -0
- package/runtime/scripts/lib/work-edition.mjs +40 -0
- package/sync/format.mjs +1 -1
- package/sync/hygiene.mjs +16 -4
- package/templates/app/hooks/husky/pre-commit +8 -7
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
// sonar-gate.mjs - the Sonar quality gate as data plus its pure judgments (knowledge/sonar-gate.yaml).
|
|
2
|
+
//
|
|
3
|
+
// The thresholds live in that one knowledge file; nothing here restates a number. Three readers use it:
|
|
4
|
+
// scripts/gates/sonar-local.mjs judges a slice's changed lines (issues, duplication, hotspots) and the coverage of
|
|
5
|
+
// every service it touched (`judgeCoverage`), judges a project's dashboard
|
|
6
|
+
// (`judgeDashboard`), and makes the server gate `gate.name` carry `serverConditions(gate)`
|
|
7
|
+
// scripts/gates/starcistacks.mjs refuses a declaration whose services.sonar.qualityGate is not `gate.name`
|
|
8
|
+
// scripts/kernel/sonar-settle.mjs `judgeSummary` reads the sonar.json an op attached and says whether the
|
|
9
|
+
// op may settle done (`enforcedOps`)
|
|
10
|
+
import fs from 'node:fs';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
import { fileURLToPath } from 'node:url';
|
|
13
|
+
import { parseYaml } from '../../engine/yaml.mjs';
|
|
14
|
+
import { braceVariants, globExpression } from '../lib/glob.mjs';
|
|
15
|
+
import { trimTrailingSlashes } from '../lib/path-key.mjs';
|
|
16
|
+
import { declarationEdition } from '../hfs/edition-slots.mjs';
|
|
17
|
+
|
|
18
|
+
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
|
|
19
|
+
const GATE_FILE = 'knowledge/sonar-gate.yaml';
|
|
20
|
+
export const GATE_SCHEMA = 'starci/sonar-gate@1';
|
|
21
|
+
export const SCAN_SCHEMA_PREFIX = 'starci/sonar-local-scan@';
|
|
22
|
+
|
|
23
|
+
let cache = null;
|
|
24
|
+
/** Read the canonical gate for the repository's declared edition; cache the document, never a selected app view. */
|
|
25
|
+
export function loadSonarGate({ base = root, file = null, cwd = null } = {}) {
|
|
26
|
+
const gate = !file && cache?.base === base ? cache.gate : parseYaml(fs.readFileSync(file ?? path.join(base, GATE_FILE), 'utf8'));
|
|
27
|
+
if (gate?.schema !== GATE_SCHEMA) throw new Error(`${file ?? GATE_FILE}: schema must be ${GATE_SCHEMA}`);
|
|
28
|
+
for (const key of ['gate', 'newCode', 'overall', 'enforcedOps'])
|
|
29
|
+
if (gate[key] == null) throw new Error(`${file ?? GATE_FILE}: ${key} is required`);
|
|
30
|
+
// Coverage is a required policy descriptor of the full block; the lite block carries the same descriptor (owner ruling 2026-10-12).
|
|
31
|
+
for (const area of ['newCode', 'overall']) {
|
|
32
|
+
const coverage = gate[area].coverage;
|
|
33
|
+
if (!coverage || typeof coverage.metric !== 'string' || !coverage.metric.trim() || !Number.isFinite(coverage.minPercent) || coverage.minPercent < 0 || coverage.minPercent > 100)
|
|
34
|
+
throw new Error(`${file ?? GATE_FILE}: ${area}.coverage requires a metric and minPercent from 0 to 100`);
|
|
35
|
+
}
|
|
36
|
+
if (!file) cache = { base, gate };
|
|
37
|
+
if (!cwd) return gate;
|
|
38
|
+
return gateForCwd(gate, { cwd, file });
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The canonical gate limited to the repository's declared edition (the lite block: its own gate name, the same conditions). */
|
|
42
|
+
const gateForCwd = (gate, { cwd, file }) => {
|
|
43
|
+
let declaration;
|
|
44
|
+
try { declaration = JSON.parse(fs.readFileSync(path.join(cwd, 'hfs.json'), 'utf8')); }
|
|
45
|
+
catch (error) {
|
|
46
|
+
if (error?.code === 'ENOENT') return gate;
|
|
47
|
+
throw error;
|
|
48
|
+
}
|
|
49
|
+
const { edition, valid } = declarationEdition({}, declaration);
|
|
50
|
+
if (!valid) throw new Error('hfs.json declares an unsupported Sonar edition');
|
|
51
|
+
if (edition === 'full') return gate;
|
|
52
|
+
if (declaration.kind !== 'app') throw new Error('hfs.json lite Sonar policy requires an app declaration');
|
|
53
|
+
const selected = gate[edition];
|
|
54
|
+
for (const key of ['gate', 'newCode', 'overall'])
|
|
55
|
+
if (selected?.[key] == null) throw new Error(`${file ?? GATE_FILE}: ${edition}.${key} is required`);
|
|
56
|
+
return { ...gate, ...selected };
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The conditions the server gate carries: [{metric, op, error}] (SonarQube api/qualitygates conditions): the new-code ones
|
|
61
|
+
* (coverage, duplication, hotspots, blocking issues), then the overall-code ones (coverage, open issues of any origin,
|
|
62
|
+
* imported HFS/ESLint/stylelint findings included, reviewed hotspots and duplication). Coverage is the services' coverage:
|
|
63
|
+
* the scope is what sonar.coverage.exclusions of the managed sonar-project.properties leaves (coverageScopeOf).
|
|
64
|
+
*/
|
|
65
|
+
export function serverConditions(gate) {
|
|
66
|
+
const n = gate.newCode;
|
|
67
|
+
const o = gate.overall;
|
|
68
|
+
const out = [
|
|
69
|
+
...(n.coverage ? [{ metric: n.coverage.metric, op: 'LT', error: String(n.coverage.minPercent) }] : []),
|
|
70
|
+
{ metric: n.duplication.metric, op: 'GT', error: String(n.duplication.maxPercent) },
|
|
71
|
+
{ metric: n.hotspots.metric, op: 'LT', error: String(n.hotspots.minReviewedPercent) },
|
|
72
|
+
];
|
|
73
|
+
for (const severity of n.issues.blockingSeverities) out.push({ metric: n.issues.metrics[severity], op: 'GT', error: String(n.issues.max) });
|
|
74
|
+
out.push(
|
|
75
|
+
...(o.coverage ? [{ metric: o.coverage.metric, op: 'LT', error: String(o.coverage.minPercent) }] : []),
|
|
76
|
+
{ metric: o.issues.metric, op: 'GT', error: String(o.issues.max) },
|
|
77
|
+
{ metric: o.hotspots.metric, op: 'LT', error: String(o.hotspots.minReviewedPercent) },
|
|
78
|
+
{ metric: o.duplication.metric, op: 'GT', error: String(o.duplication.maxPercent) },
|
|
79
|
+
);
|
|
80
|
+
return out;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The ops the runtime holds to the gate at settle. */
|
|
84
|
+
export const enforcedOps = (gate) => new Set(gate.enforcedOps);
|
|
85
|
+
|
|
86
|
+
/** What the slice verdict is judged against: the numbers a scan copies into its summary (`gate`). */
|
|
87
|
+
export function thresholdsOf(gate) {
|
|
88
|
+
const n = gate.newCode;
|
|
89
|
+
return {
|
|
90
|
+
name: gate.gate.name,
|
|
91
|
+
ignoreBelowChangedLines: n.ignoreBelowChangedLines,
|
|
92
|
+
duplicationMaxPercent: n.duplication.maxPercent,
|
|
93
|
+
blockingSeverities: [...n.issues.blockingSeverities],
|
|
94
|
+
blockingIssuesMax: n.issues.max,
|
|
95
|
+
unreviewedHotspotsMax: n.hotspots.unreviewedMax,
|
|
96
|
+
coverageMinPercent: gate.overall.coverage?.minPercent ?? null,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const listOf = (props, key) => String(props[key] ?? '').split(',').map((glob) => glob.trim()).filter(Boolean);
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The coverage scope of a sonar-project.properties map (readProperties), as Sonar computes it: SonarQube has no coverage
|
|
104
|
+
* inclusions, so the files it measures coverage on are the source files of `sonar.sources` that neither `sonar.exclusions`,
|
|
105
|
+
* `sonar.coverage.exclusions` nor the test patterns (`sonar.test.inclusions`) take. The managed properties render
|
|
106
|
+
* sonar.coverage.exclusions as the complement of the services (starci app sync coverageExclusions), so what is left is the services.
|
|
107
|
+
* Returns {exclusions, sources, excluded, tests}; `exclusions` empty means the repository declares no coverage scope.
|
|
108
|
+
*/
|
|
109
|
+
export function coverageScopeOf(props = {}) {
|
|
110
|
+
return { exclusions: listOf(props, 'sonar.coverage.exclusions'), sources: listOf(props, 'sonar.sources'), excluded: listOf(props, 'sonar.exclusions'), tests: listOf(props, 'sonar.test.inclusions') };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const SOURCE_FILE = /\.(?:[cm]?[jt]sx?)$/;
|
|
114
|
+
|
|
115
|
+
/** True for a repository-relative path Sonar measures coverage on under `scope` (coverageScopeOf; Sonar's glob subset, scripts/lib/glob.mjs). */
|
|
116
|
+
export function coverageTargetOf(scope) {
|
|
117
|
+
const patterns = (globs) => (globs ?? []).flatMap(braceVariants).map(globExpression);
|
|
118
|
+
const excluded = patterns([...(scope.exclusions ?? []), ...(scope.excluded ?? [])]);
|
|
119
|
+
const tests = patterns(scope.tests);
|
|
120
|
+
const roots = (scope.sources ?? []).map((dir) => `${trimTrailingSlashes(String(dir))}/`);
|
|
121
|
+
return (file) => {
|
|
122
|
+
const rel = String(file).replaceAll('\\', '/');
|
|
123
|
+
return SOURCE_FILE.test(rel) && (!roots.length || roots.some((dir) => rel.startsWith(dir)))
|
|
124
|
+
&& !excluded.some((pattern) => pattern.test(rel)) && !tests.some((pattern) => pattern.test(rel));
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const asNumber = (value) => (value === undefined || value === null || value === '' || !Number.isFinite(Number(value)) ? null : Number(value));
|
|
129
|
+
const byPath = (a, b) => {
|
|
130
|
+
if (a.path < b.path) return -1;
|
|
131
|
+
if (a.path > b.path) return 1;
|
|
132
|
+
return 0;
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* The per-file coverage verdict: `files` are [{path, coverage}] (coverage the Sonar `coverage` measure of the file, a number
|
|
137
|
+
* or null when Sonar has none), `scope` the repository's coverage scope (coverageScopeOf). Only a file of the scope is a
|
|
138
|
+
* coverage target: every other file (a handler, a resolver, a module, a config file, a test, anything under fe/) is not
|
|
139
|
+
* judged and not listed, whatever its measure. A target below `minPercent` fails, and so does a target Sonar holds no
|
|
140
|
+
* coverage for (the lcov report was not imported or does not name it): a missing number is never a pass. With no
|
|
141
|
+
* sonar.coverage.exclusions the repository declares no coverage scope and nothing is judged (`applied` false).
|
|
142
|
+
* Returns {applied, exclusions, minPercent, files: [{path, coverage, ok}], failures: [text]}.
|
|
143
|
+
*/
|
|
144
|
+
export function judgeCoverage(files, { scope = coverageScopeOf(), minPercent }) {
|
|
145
|
+
const isTarget = coverageTargetOf(scope);
|
|
146
|
+
const result = { applied: scope.exclusions.length > 0, exclusions: [...scope.exclusions], minPercent, files: [], failures: [] };
|
|
147
|
+
if (!result.applied) return { ...result, note: 'the repository declares no sonar.coverage.exclusions: no coverage scope' };
|
|
148
|
+
for (const file of [...files].sort(byPath)) {
|
|
149
|
+
const rel = String(file.path).replaceAll('\\', '/');
|
|
150
|
+
if (!isTarget(rel)) continue;
|
|
151
|
+
const coverage = asNumber(file.coverage);
|
|
152
|
+
const ok = coverage !== null && coverage >= minPercent;
|
|
153
|
+
result.files.push({ path: rel, coverage, ok });
|
|
154
|
+
if (coverage === null) result.failures.push(`${rel} has no coverage measure (the be unit run's lcov is not imported or does not name it)`);
|
|
155
|
+
else if (!ok) result.failures.push(`coverage of ${rel} ${coverage}% < ${minPercent}%`);
|
|
156
|
+
}
|
|
157
|
+
return result;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** The issue, hotspot and duplication measures of the dashboard: their numbers and failures. */
|
|
161
|
+
const measureFailures = (o, measures, numbers) => {
|
|
162
|
+
const failures = [];
|
|
163
|
+
for (const metric of Object.keys(o.issues.types)) {
|
|
164
|
+
numbers[metric] = asNumber(measures[metric]);
|
|
165
|
+
if (numbers[metric] === null) failures.push(`${metric} is not measured`);
|
|
166
|
+
else if (numbers[metric] > o.issues.max) failures.push(`${metric} ${numbers[metric]} > ${o.issues.max}`);
|
|
167
|
+
}
|
|
168
|
+
const reviewed = asNumber(measures[o.hotspots.metric]);
|
|
169
|
+
numbers.security_hotspots = asNumber(measures.security_hotspots);
|
|
170
|
+
numbers[o.hotspots.metric] = reviewed === null && numbers.security_hotspots === 0 ? 100 : reviewed;
|
|
171
|
+
if (numbers[o.hotspots.metric] === null) failures.push(`${o.hotspots.metric} is not measured`);
|
|
172
|
+
else if (numbers[o.hotspots.metric] < o.hotspots.minReviewedPercent) failures.push(`${o.hotspots.metric} ${numbers[o.hotspots.metric]}% < ${o.hotspots.minReviewedPercent}%`);
|
|
173
|
+
numbers[o.duplication.metric] = asNumber(measures[o.duplication.metric]);
|
|
174
|
+
if (numbers[o.duplication.metric] === null) failures.push(`${o.duplication.metric} is not measured`);
|
|
175
|
+
else if (numbers[o.duplication.metric] > o.duplication.maxPercent) failures.push(`${o.duplication.metric} ${numbers[o.duplication.metric]}% > ${o.duplication.maxPercent}%`);
|
|
176
|
+
return failures;
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
/** The coverage block of the dashboard verdict: {coverage, failures}. */
|
|
180
|
+
const coverageBlock = (o, files, scope, measures, numbers) => {
|
|
181
|
+
const coverage = o.coverage ? judgeCoverage(files, { scope, minPercent: o.coverage.minPercent })
|
|
182
|
+
: { applied: false, status: 'not-required', files: [], failures: [], note: 'the declared Sonar policy has no coverage condition' };
|
|
183
|
+
const failures = [];
|
|
184
|
+
if (o.coverage) {
|
|
185
|
+
numbers[o.coverage.metric] = asNumber(measures[o.coverage.metric]);
|
|
186
|
+
if (!coverage.applied) failures.push("the repository declares no sonar.coverage.exclusions: the services' coverage cannot be judged");
|
|
187
|
+
else if (!coverage.files.length) failures.push('no file of the coverage scope is measured: the scope is empty or the lcov report was not imported');
|
|
188
|
+
failures.push(...coverage.failures);
|
|
189
|
+
if (numbers[o.coverage.metric] === null) failures.push(`${o.coverage.metric} is not measured`);
|
|
190
|
+
else if (numbers[o.coverage.metric] < o.coverage.minPercent) failures.push(`${o.coverage.metric} ${numbers[o.coverage.metric]}% < ${o.coverage.minPercent}%`);
|
|
191
|
+
}
|
|
192
|
+
return { coverage, failures };
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* The dashboard verdict of a whole project (`sonar-local dashboard`): `measures` the project's measures by metric key
|
|
197
|
+
* (bugs, code_smells, vulnerabilities, security_hotspots, security_hotspots_reviewed, duplicated_lines_density, coverage), `files` the per-file
|
|
198
|
+
* coverage of the project (as judgeCoverage takes it), `scope` its coverage scope (coverageScopeOf). It fails unless every
|
|
199
|
+
* issue type of `overall.issues.types` is at `overall.issues.max`, every hotspot is reviewed (a project with no hotspot has
|
|
200
|
+
* none to review), duplication meets its declared maximum, and required coverage meets its per-file threshold. Returns {verdict, numbers, coverage, failures}.
|
|
201
|
+
*/
|
|
202
|
+
export function judgeDashboard({ measures = {}, files = [], scope = coverageScopeOf() }, gate) {
|
|
203
|
+
const o = gate.overall;
|
|
204
|
+
const numbers = {};
|
|
205
|
+
const failures = [...measureFailures(o, measures, numbers)];
|
|
206
|
+
const block = coverageBlock(o, files, scope, measures, numbers);
|
|
207
|
+
failures.push(...block.failures);
|
|
208
|
+
return { verdict: failures.length ? 'fail' : 'pass', numbers, coverage: block.coverage, failures };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* What a filed sonar.json says, as a settle judgment. `summary` is the parsed scan summary (or null when the op attached
|
|
213
|
+
* none). Returns {status, code, detail, findings[]}:
|
|
214
|
+
* pass the slice meets the gate (or Sonar is disabled by the repository's own declaration - noted)
|
|
215
|
+
* red the slice fails the gate on new code: findings[] names every failing condition
|
|
216
|
+
* unavailable the scan could not run (server down, custody missing, token rejected): never a pass
|
|
217
|
+
* refused the scan was refused before it ran (unknown base, empty slice ...): the op fixes and reruns
|
|
218
|
+
* missing no sonar.json (or one that is not a scan summary) is attached
|
|
219
|
+
* A slice that changed no file is a pass with `note`: there is nothing new to fail.
|
|
220
|
+
* Owner mode: a summary that says `ownerMode.coverage: not-measured` (the owner's specs.unit off) passes on its other
|
|
221
|
+
* conditions with `coverage: 'not-measured'` and the owner-mode `note`, never as a plain pass, and only when `specs` (the
|
|
222
|
+
* owner switches the caller read) confirms unit is off; otherwise the claim is refused as sonar-proof-missing.
|
|
223
|
+
*/
|
|
224
|
+
export function judgeSummary(summary, gate, { specs = null } = {}) {
|
|
225
|
+
if (!summary || typeof summary !== 'object' || !String(summary.schema ?? '').startsWith(SCAN_SCHEMA_PREFIX))
|
|
226
|
+
return { status: 'missing', code: 'sonar-proof-missing', detail: 'no sonar.json scan summary (schema starci/sonar-local-scan) is attached to the report', findings: [] };
|
|
227
|
+
const why = String(summary.reason ?? '').replace(/\s+/g, ' ').slice(0, 300);
|
|
228
|
+
switch (summary.outcome) {
|
|
229
|
+
case 'pass':
|
|
230
|
+
if (summary.scope !== 'slice') return { status: 'missing', code: 'sonar-proof-missing', detail: `the summary's scope is ${summary.scope ?? 'unknown'}, not the slice's own (scope slice is required)`, findings: [] };
|
|
231
|
+
// Owner mode (specs.unit off): the slice passes on the other conditions, and the judgment says coverage was not measured.
|
|
232
|
+
if (summary.ownerMode && specs?.unit !== false) return { status: 'missing', code: 'sonar-proof-missing', detail: 'the summary claims owner mode specs.unit=false (coverage not measured) but the owner config has unit tests on: rerun the scan', findings: [] };
|
|
233
|
+
if (summary.ownerMode?.coverage === 'not-measured') return { status: 'pass', code: null, detail: null, coverage: 'not-measured', note: String(summary.ownerMode.note ?? 'owner mode: coverage not measured'), findings: [] };
|
|
234
|
+
return { status: 'pass', code: null, detail: null, findings: [] };
|
|
235
|
+
case 'fail': {
|
|
236
|
+
const findings = Array.isArray(summary.slice?.failures) && summary.slice.failures.length ? summary.slice.failures.map(String) : [why || 'the slice fails the gate'];
|
|
237
|
+
return { status: 'red', code: 'sonar-gate-red', detail: findings.join('; '), findings };
|
|
238
|
+
}
|
|
239
|
+
case 'blocked':
|
|
240
|
+
return { status: 'unavailable', code: 'sonar-unavailable', detail: why || 'Sonar could not judge the slice', findings: [] };
|
|
241
|
+
case 'disabled':
|
|
242
|
+
return { status: 'pass', code: null, detail: null, note: `Sonar is disabled by the repository's declaration: ${why}`, findings: [] };
|
|
243
|
+
case 'refused':
|
|
244
|
+
if (summary.code === 'SLICE_EMPTY') return { status: 'pass', code: null, detail: null, note: 'the slice changed no file, so there is no new code to judge', findings: [] };
|
|
245
|
+
return { status: 'refused', code: 'sonar-scan-refused', detail: `${summary.code ?? 'refused'}: ${why}`, findings: [] };
|
|
246
|
+
case 'submitted':
|
|
247
|
+
return { status: 'missing', code: 'sonar-proof-missing', detail: 'the scan was submitted without --wait: no processed verdict', findings: [] };
|
|
248
|
+
default:
|
|
249
|
+
return { status: 'missing', code: 'sonar-proof-missing', detail: `the summary has no verdict (outcome ${summary.outcome ?? 'absent'})`, findings: [] };
|
|
250
|
+
}
|
|
251
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// starcistacks-ci.mjs - service catalog, CI declarations and quality-service example policy.
|
|
2
|
+
import { readCiDeclaration } from '../hfs/ci-declaration.mjs';
|
|
3
|
+
|
|
4
|
+
/** The closed service catalog: its providers and the CI text that shows a workflow calls it. */
|
|
5
|
+
export const SERVICE_CATALOG = {
|
|
6
|
+
codecov: { providers: ['codecov'], words: ['codecov'], ci: [/codecov\/codecov-action/i] },
|
|
7
|
+
dependabot: { providers: ['github'], words: ['dependabot'], ci: [] },
|
|
8
|
+
audit: { providers: ['npm'], words: ['audit'], ci: [/\bnpm audit\b/] },
|
|
9
|
+
sonar: { providers: ['sonarqube', 'sonarcloud'], words: ['sonar', 'sonarqube', 'sonarcloud'],
|
|
10
|
+
ci: [/SonarSource\/sonar(?:qube|cloud)-[a-z-]+-action/i, /\bsonar-scanner\b/i, /\bSONAR_TOKEN\b/] },
|
|
11
|
+
'container-registry': { providers: ['ghcr', 'dockerhub', 'ecr', 'gar'], words: ['ghcr', 'registry', 'docker hub', 'dockerhub'],
|
|
12
|
+
ci: [/docker\/login-action/i] },
|
|
13
|
+
analytics: { providers: ['posthog', 'plausible', 'umami', 'google-analytics'], words: ['posthog', 'plausible', 'umami', 'analytics'], ci: [] },
|
|
14
|
+
'error-tracking': { providers: ['sentry', 'glitchtip'], words: ['sentry', 'glitchtip'], ci: [/getsentry\/action-release/i, /\bSENTRY_AUTH_TOKEN\b/] },
|
|
15
|
+
};
|
|
16
|
+
export const checkServiceForm = (service, id, entry, at, kind, add, ambiguous) => {
|
|
17
|
+
if (service.provider && !SERVICE_CATALOG[id].providers.includes(service.provider))
|
|
18
|
+
add('refuse', 'STACKS_PROVIDER_UNKNOWN', `${at}.provider`, `${service.provider} is not a ${id} provider (${SERVICE_CATALOG[id].providers.join(', ')})`);
|
|
19
|
+
if (Object.hasOwn(entry?.ci ?? {}, 'secrets') || Object.hasOwn(entry?.ci ?? {}, 'vars'))
|
|
20
|
+
add('suspect', 'STACKS_CI_LEGACY_FORM', `${at}.ci`, 'move the names to the top-level ci block and name them by services.<id>.ci.token / hostVar');
|
|
21
|
+
if (id === 'sonar' && service.provider === 'sonarcloud' && !service.organization
|
|
22
|
+
&& !(kind === 'example' && service.runtimeSecrets.length && !service.credentials.length && service.ci.organizationVar))
|
|
23
|
+
ambiguous('provider sonarcloud requires organization; the runtime example form may use ci.organizationVar');
|
|
24
|
+
if (kind === 'example' && id === 'sonar' && service.provider !== 'sonarcloud')
|
|
25
|
+
add('refuse', 'STACKS_EXAMPLE_FORM', at, 'an example sonar provider must be sonarcloud');
|
|
26
|
+
if (kind === 'example' && id === 'codecov' && (service.auth !== 'oidc' || service.ci.token))
|
|
27
|
+
add('refuse', 'STACKS_EXAMPLE_FORM', at, 'an example codecov service uses auth oidc without a token');
|
|
28
|
+
};
|
|
29
|
+
/** Examples may document optional workflow secret names, but never a Codecov token or a required extra secret. */
|
|
30
|
+
function forbiddenExampleSecret(item) {
|
|
31
|
+
return item.name === ('CODECOV' + '_TOKEN') || (item.name !== 'SONAR_TOKEN' && item.optional !== true);
|
|
32
|
+
}
|
|
33
|
+
/** Validate the additive CI declaration and the public example secret policy. */
|
|
34
|
+
export function checkCiDeclaration(declaration, declFile, kind, declarationSchema, schemaErrors, add) {
|
|
35
|
+
if (declaration?.doc && Object.hasOwn(declaration.doc, 'ci')) {
|
|
36
|
+
for (const issue of schemaErrors(declaration.doc.ci, declarationSchema().$defs.ciDeclaration, 'ci'))
|
|
37
|
+
add('refuse', 'CI_DECLARATION_INVALID', `${declFile}#${issue.path}`, issue.message);
|
|
38
|
+
const ci = readCiDeclaration(declaration.doc);
|
|
39
|
+
for (const problem of ci.problems) add('refuse', problem.code, `${declFile}#${problem.at}`, problem.message);
|
|
40
|
+
if (kind === 'example' && ci.secrets.some(forbiddenExampleSecret))
|
|
41
|
+
add('refuse', 'STACKS_EXAMPLE_FORM', `${declFile}#ci.secrets`, 'STACKS_EXAMPLE_FORM: an example declares CI secrets other than SONAR_TOKEN only with optional: true to document workflow names; no Codecov token or stored credentials');
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Quality services use their CI binding without requiring hosted project or custody fields. */
|
|
46
|
+
export function checkQualityService(service, id, at, context, checkServiceCi) {
|
|
47
|
+
const { ambiguous } = context;
|
|
48
|
+
if (['codecov', 'dependabot', 'audit'].includes(id)) {
|
|
49
|
+
if (id === 'codecov' && !['oidc', 'token'].includes(service.auth)) ambiguous('codecov auth must be oidc or token');
|
|
50
|
+
if (id === 'codecov' && service.auth === 'token' && !service.ci.token) ambiguous('codecov auth token requires ci.token');
|
|
51
|
+
checkServiceCi(service, id, at, context);
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// starcistacks-form.mjs - where a secret may live, per repository kind: the example form and the product form of a Sonar declaration (docs/application-stacks.md, "Where a secret may live").
|
|
2
|
+
// An example is an app inside the public runtime repository, which holds no secret file: it declares SonarCloud (provider sonarcloud, mode hosted) and names the variables of the runtime's untracked
|
|
3
|
+
// secret.env it reads (runtimeSecrets: the token only; the organization is configuration, config.yaml sonar.organization), with no stack, no host.local and no custody credential. A product keeps its own custody and declares no runtimeSecrets. The refusals are STACKS_EXAMPLE_FORM
|
|
4
|
+
// and STACKS_PRODUCT_FORM (scripts/gates/starcistacks.mjs calls this module for each enabled service).
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
|
|
7
|
+
const EXAMPLE_NAMES = Object.freeze(['SONAR_TOKEN']);
|
|
8
|
+
|
|
9
|
+
/** 'example' for an app that lives inside the runtime repository (its checkout sits under the runtime's examples/), else 'product'. */
|
|
10
|
+
export function kindOf(repo, examplesRoot) {
|
|
11
|
+
const relative = path.relative(path.resolve(examplesRoot), repo);
|
|
12
|
+
return relative && !relative.startsWith('..') && !path.isAbsolute(relative) ? 'example' : 'product';
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Refuse the form of the other kind in one normalized Sonar service: `add(level, code, where, message)` records a finding. */
|
|
16
|
+
export function checkSecretForm(service, id, at, kind, add) {
|
|
17
|
+
if (id !== 'sonar' || service.mode === 'disabled') return;
|
|
18
|
+
if (kind === 'product') {
|
|
19
|
+
if (service.runtimeSecrets.length) add('refuse', 'STACKS_PRODUCT_FORM', `${at}.runtimeSecrets`, 'runtimeSecrets name variables of the runtime repository secret.env, which a product does not read; a product declares its credentials in its own custody');
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
const wrong = [service.stack && 'a stack', service.host.local && 'host.local', service.credentials.length && 'custody credentials'].filter(Boolean);
|
|
23
|
+
if (wrong.length) add('refuse', 'STACKS_EXAMPLE_FORM', at, `an example of the runtime repository declares ${wrong.join(', ')}; the runtime repository holds no secret file, so an example declares provider sonarcloud, mode hosted and runtimeSecrets [${EXAMPLE_NAMES.join(', ')}]`);
|
|
24
|
+
if (service.provider !== 'sonarcloud' || service.mode !== 'hosted') add('refuse', 'STACKS_EXAMPLE_FORM', `${at}.provider`, 'an example of the runtime repository is analysed on SonarCloud: provider sonarcloud and mode hosted');
|
|
25
|
+
if (service.runtimeSecrets.join() !== EXAMPLE_NAMES.join()) add('refuse', 'STACKS_EXAMPLE_FORM', `${at}.runtimeSecrets`, `an example names the one secret.env variable it reads: runtimeSecrets [${EXAMPLE_NAMES.join(', ')}]; the SonarCloud organization is configuration (config.yaml sonar.organization), not a secret`);
|
|
26
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
const LINE_TERMINATOR = /[\n\r\u2028\u2029]/;
|
|
2
|
+
const VALUE_EXTENSIONS = ['env', 'key', 'pem', 'tfvars'];
|
|
3
|
+
|
|
4
|
+
/** The filenames matched by the declaration's former infrastructure-value-file expression. */
|
|
5
|
+
export const isInfrastructureValueFile = (value) => {
|
|
6
|
+
const name = String(value);
|
|
7
|
+
if (LINE_TERMINATOR.test(name)) return false;
|
|
8
|
+
if (name === '.env' || (name.startsWith('.env.') && name.length > '.env.'.length)) return true;
|
|
9
|
+
for (let dot = name.indexOf('.', 1); dot !== -1; dot = name.indexOf('.', dot + 1)) {
|
|
10
|
+
for (const extension of VALUE_EXTENSIONS) {
|
|
11
|
+
if (!name.startsWith(extension, dot + 1)) continue;
|
|
12
|
+
const after = dot + 1 + extension.length;
|
|
13
|
+
if (after === name.length || (name[after] === '.' && after + 1 < name.length)) return true;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
return false;
|
|
17
|
+
};
|