@starci/hfs 4.5.1 → 4.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +11 -0
- package/ci/cli.mjs +8 -4
- package/ci/local.mjs +146 -0
- package/ci/vars.mjs +22 -5
- package/ci/verify.mjs +16 -2
- package/lint/run.mjs +3 -4
- package/package.json +1 -1
- package/release/candidate-ci.mjs +111 -0
- package/release/gate.mjs +4 -5
- package/release/push.mjs +10 -1
- package/runtime/knowledge/hfs/canon-pins.yaml +9 -4
- package/runtime/knowledge/hfs/ci-standard.yaml +103 -0
- package/runtime/knowledge/hfs/rules.yaml +19 -2
- package/runtime/knowledge/sonar-gate.yaml +2 -2
- package/runtime/modules/kernel/failure-codes.yaml +10 -0
- package/runtime/modules/schemas/application-stacks.schema.yaml +56 -0
- package/runtime/packages/cli/src/catalog.generated.mjs +121 -36
- package/runtime/scripts/hfs/ci-declaration.mjs +10 -1
- package/runtime/scripts/hfs/lcov-merge.mjs +89 -0
- package/runtime/scripts/hfs/rules/a15-findings.mjs +3 -0
- package/runtime/scripts/hfs/rules/ci-standard.mjs +202 -0
- package/runtime/scripts/hfs/rules/coverage-below.mjs +1 -1
- package/runtime/scripts/hfs/rules/deploy-workflow.mjs +14 -5
- package/runtime/scripts/hfs/rules/secrets.mjs +8 -10
- package/runtime/scripts/hfs/rules/workflow-contexts.mjs +1 -1
- package/runtime/scripts/lib/secret-patterns.mjs +77 -0
- package/scaffold/app.mjs +5 -4
- package/src/main.mjs +4 -2
- package/sync/ci-commands.mjs +81 -27
- package/sync/hygiene.mjs +4 -1
- package/sync/index.mjs +14 -11
- package/sync/quality-services.mjs +7 -5
- package/sync/readme-badges.mjs +18 -2
- package/sync/stack-images.mjs +5 -0
- package/templates/app/ci-workflows/build-cache-save.yml +8 -0
- package/templates/app/ci-workflows/github/workflows/ci.yml +52 -12
- package/templates/app/ci-workflows/install-steps.yml +3 -2
- package/templates/app/ci-workflows/jest-cache-save.yml +6 -0
- package/templates/app/ci-workflows/jest-cache.yml +4 -4
- package/templates/app/ci-workflows-lite/github/workflows/ci.yml +53 -14
- package/templates/app/hooks/husky/pre-push +4 -2
- package/templates/app/skeleton/.github/dependabot.yml +1 -2
- package/templates/app/skeleton-lite/.github/dependabot.yml +1 -2
- package/release/ci-red.mjs +0 -70
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// lcov-merge.mjs - the lcov reports of the sharded unit run merged into one (`starci app coverage merge`, the coverage job of the managed CI). Each shard of
|
|
2
|
+
// `npm test -- --shard=i/N` writes its own lcov over the whole collectCoverageFrom scope (a file no test of that shard loads is a record of zeros), so the
|
|
3
|
+
// merge sums every line, function and branch hit by its identity across the shards and writes ONE record per source file, with the summaries recomputed:
|
|
4
|
+
// the coverage proof (`starci app check --coverage-from`), the Codecov upload and the Sonar import then read the one be/coverage/lcov.info they always read.
|
|
5
|
+
// Pure over the texts; the file walk is lcovFilesUnder.
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { byCodeUnit } from '../lib/list.mjs';
|
|
9
|
+
import { addHits as add } from './rules/coverage-below.mjs';
|
|
10
|
+
|
|
11
|
+
const count = (value) => (/^\d+$/.test(value) ? Number(value) : 0);
|
|
12
|
+
|
|
13
|
+
function recordOf(records, source) {
|
|
14
|
+
if (!records.has(source)) records.set(source, { lines: new Map(), fnLines: new Map(), fnHits: new Map(), branches: new Map() });
|
|
15
|
+
return records.get(source);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Function data: FN:<line>[,<end>],<name> declares, FNDA:<hits>,<name> counts (a name may hold commas). */
|
|
19
|
+
function functionLine(record, tag, value) {
|
|
20
|
+
const at = value.indexOf(',');
|
|
21
|
+
if (at < 0) return;
|
|
22
|
+
if (tag === 'FN') {
|
|
23
|
+
let name = value.slice(at + 1);
|
|
24
|
+
let line = value.slice(0, at);
|
|
25
|
+
if (/^\d+,/.test(name)) { line = `${line},${name.slice(0, name.indexOf(','))}`; name = name.slice(name.indexOf(',') + 1); }
|
|
26
|
+
if (!record.fnLines.has(name)) record.fnLines.set(name, line);
|
|
27
|
+
if (!record.fnHits.has(name)) record.fnHits.set(name, 0);
|
|
28
|
+
} else add(record.fnHits, value.slice(at + 1), count(value.slice(0, at)));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function dataLine(record, tag, value) {
|
|
32
|
+
if (tag === 'DA') {
|
|
33
|
+
const [line, hits] = value.split(',');
|
|
34
|
+
add(record.lines, Number(line), count(hits ?? ''));
|
|
35
|
+
} else if (tag === 'FN' || tag === 'FNDA') functionLine(record, tag, value);
|
|
36
|
+
else if (tag === 'BRDA') {
|
|
37
|
+
const parts = value.split(',');
|
|
38
|
+
if (parts.length === 4) add(record.branches, parts.slice(0, 3).join(','), parts[3] === '-' ? 0 : count(parts[3]));
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Every record of every text, merged by source file (SF) and identity. */
|
|
43
|
+
function parseAll(texts) {
|
|
44
|
+
const records = new Map();
|
|
45
|
+
for (const text of texts) {
|
|
46
|
+
let current = null;
|
|
47
|
+
for (const raw of String(text).replace(/^/, '').split(/\r?\n/)) {
|
|
48
|
+
const line = raw.trim();
|
|
49
|
+
if (line === 'end_of_record') { current = null; continue; }
|
|
50
|
+
const colon = line.indexOf(':');
|
|
51
|
+
if (colon < 0) continue;
|
|
52
|
+
const tag = line.slice(0, colon);
|
|
53
|
+
const value = line.slice(colon + 1);
|
|
54
|
+
if (tag === 'SF') current = recordOf(records, value);
|
|
55
|
+
else if (current) dataLine(current, tag, value);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return records;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const branchOrder = (a, b) => a.split(',').map(Number).reduce((order, part, index) => order || part - Number(b.split(',')[index]), 0);
|
|
62
|
+
|
|
63
|
+
function serialize(source, record) {
|
|
64
|
+
const out = ['TN:', `SF:${source}`];
|
|
65
|
+
const functions = [...record.fnLines.keys()].sort((a, b) => Number(record.fnLines.get(a).split(',')[0]) - Number(record.fnLines.get(b).split(',')[0]) || byCodeUnit(a, b));
|
|
66
|
+
for (const name of functions) out.push(`FN:${record.fnLines.get(name)},${name}`);
|
|
67
|
+
for (const name of functions) out.push(`FNDA:${record.fnHits.get(name) ?? 0},${name}`);
|
|
68
|
+
out.push(`FNF:${functions.length}`, `FNH:${functions.filter((name) => (record.fnHits.get(name) ?? 0) > 0).length}`);
|
|
69
|
+
const lines = [...record.lines.keys()].sort((a, b) => a - b);
|
|
70
|
+
for (const line of lines) out.push(`DA:${line},${record.lines.get(line)}`);
|
|
71
|
+
out.push(`LF:${lines.length}`, `LH:${lines.filter((line) => record.lines.get(line) > 0).length}`);
|
|
72
|
+
const branches = [...record.branches.keys()].sort(branchOrder);
|
|
73
|
+
for (const branch of branches) out.push(`BRDA:${branch},${record.branches.get(branch)}`);
|
|
74
|
+
out.push(`BRF:${branches.length}`, `BRH:${branches.filter((branch) => record.branches.get(branch) > 0).length}`, 'end_of_record');
|
|
75
|
+
return out.join('\n');
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** One lcov text of the merged `texts`: a record per source file, sorted by path, hits summed by line, function name and branch identity. */
|
|
79
|
+
export function mergeLcov(texts) {
|
|
80
|
+
const records = parseAll(texts);
|
|
81
|
+
return [...records.keys()].sort(byCodeUnit).map((source) => serialize(source, records.get(source))).join('\n') + (records.size ? '\n' : '');
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** The lcov files a path names: the file itself, or every `lcov.info` below a directory (sorted). */
|
|
85
|
+
export function lcovFilesUnder(target) {
|
|
86
|
+
if (!fs.statSync(target).isDirectory()) return [target];
|
|
87
|
+
return fs.readdirSync(target, { recursive: true, withFileTypes: true }).filter((entry) => entry.isFile() && entry.name === 'lcov.info')
|
|
88
|
+
.map((entry) => path.join(entry.parentPath ?? entry.path, entry.name)).sort(byCodeUnit);
|
|
89
|
+
}
|
|
@@ -34,6 +34,7 @@ import { stackToolingFindings } from './stack-tooling.mjs';
|
|
|
34
34
|
import { stacksDeployFindings } from './stacks-deploy.mjs';
|
|
35
35
|
import { ciCustodyFindings } from './ci-custody.mjs';
|
|
36
36
|
import { workflowContextsFindings } from './workflow-contexts.mjs';
|
|
37
|
+
import { ciStandardAppFindings } from './ci-standard.mjs';
|
|
37
38
|
import { feBuildArgsFindings } from './fe-build-args.mjs';
|
|
38
39
|
// finders-import-end
|
|
39
40
|
|
|
@@ -42,6 +43,7 @@ export const A15_CODES = [
|
|
|
42
43
|
// codes-begin
|
|
43
44
|
'FE_PUBLIC_BUILD_ARG_MISSING',
|
|
44
45
|
'HFS_WORKFLOW_CONTEXT',
|
|
46
|
+
'CI_STANDARD',
|
|
45
47
|
'CI_SECRET_SEALED', 'APP_SECRET_IN_CI', 'CI_DECLARATION_DRIFT',
|
|
46
48
|
'BACKUP_PROOF_MISSING', 'BACKUP_UNDECLARED', 'BE_APP_BOOT_PROOF_MISSING', 'BE_APP_ENV_REQUIRED', 'BE_APP_MODULE_LIST_INLINE', 'BE_LITE_PROOF_LANE_MISSING', 'DB_API_EXPOSED', 'DB_ARCHIVE_MUTATED', 'DB_BASELINE_SHAPE', 'DB_CRON_OWNED', 'DB_EXTENSION_ALLOWED', 'DB_MIGRATION_DESTRUCTIVE', 'DB_PGMQ_DECLARED', 'DB_VAULT_WRITE', 'ENV_DUPLICATE', 'ENV_EXAMPLE_DRIFT', 'ENV_PUBLIC_SEALED', 'ENV_REQUIRED_UNSET', 'ENV_SEALED_UNREAD', 'ENV_VALUE_SHAPE', 'FE_STACK_CORE_MISSING', 'FE_STACK_UNLISTED', 'HFS_AGE_KEY_CUSTODY', 'HFS_APP_LEXICON', 'HFS_APP_WORKFLOW_TRIGGER', 'HFS_ASSET_KIND', 'HFS_ASSET_OVERSIZE', 'HFS_AUDIT_STEP_MISSING', 'HFS_CANON_COPY_DRIFT', 'HFS_CI_TOOL_INSTALL', 'HFS_COVERAGE_BELOW', 'HFS_DB_DEPLOY_UNGUARDED', 'HFS_DEPENDABOT_MISSING', 'HFS_DEPLOY_COMPOSE_ON_VPS', 'HFS_DEPLOY_NO_LOGIC', 'HFS_DEPLOY_SCHEDULER', 'HFS_DEPLOY_WORKFLOW', 'HFS_DOC_COMMAND_UNKNOWN', 'HFS_DOC_ENV_UNKNOWN', 'HFS_DOC_PATH_MISSING', 'HFS_DOC_RULE_UNKNOWN', 'HFS_FEATURE_FILE_BUDGET', 'HFS_HOST_CRON', 'HFS_RELEASE_TAG_MISMATCH', 'HFS_SBOM_MISSING', 'HFS_SCHEDULE_UNDECLARED', 'HFS_SCRIPT_PLAINTEXT_LOAD', 'HFS_SCRIPT_XTRACE', 'HFS_SECRET_OUTSIDE_ENC', 'HFS_SECRET_PLAINTEXT_FILE', 'HFS_SEED_PII', 'HFS_SERVICE_ROLE_SCOPE', 'HFS_UNUSED_SYMBOL', 'HFS_WORKFLOW_PIN', 'STACKS_DEPLOY_CONFIG_INVALID', 'STACKS_DNS_UNDECLARED', 'STACKS_HEALTHCHECK_MISSING', 'STACKS_HOST_UNROUTED', 'STACKS_INGRESS_UNDECLARED', 'STACKS_INGRESS_UNKNOWN', 'STACKS_LITE_SELFHOSTED_DB', 'STACKS_MODEL_UNAVAILABLE', 'STACKS_PLACEMENT_MISSING', 'STACKS_PORT_PUBLISHED', 'STACKS_REALTIME_SCALED', 'STACKS_RELEASE_UNDECLARED', 'STACKS_SECOND_INGRESS', 'STACKS_SECRET_ENV_LITERAL', 'STACKS_SECRET_NOT_EXTERNAL', 'STACKS_SECRET_UNVERSIONED', 'STACKS_SIDECAR_DB_UNSUPPORTED', 'STACKS_SIDECAR_VOLUME_UNDECLARED', 'STACKS_TLS_UNDECLARED', 'STACKS_UPDATE_POLICY_MISSING', 'STACK_TOOLING_REIMPLEMENTED',
|
|
47
49
|
// codes-end
|
|
@@ -51,6 +53,7 @@ const FINDERS = [
|
|
|
51
53
|
// finders-begin
|
|
52
54
|
feBuildArgsFindings,
|
|
53
55
|
workflowContextsFindings,
|
|
56
|
+
ciStandardAppFindings,
|
|
54
57
|
featureFileFindings,
|
|
55
58
|
lexiconFindings,
|
|
56
59
|
assetsFindings,
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
// ci-standard.mjs - R301 CI_STANDARD: the one StarCi CI standard (knowledge/hfs/ci-standard.yaml, OWNER RULING ci-standard 2026-10-18: ".claude defines ONE standard CI
|
|
2
|
+
// ruleset, its own CI and the examples follow it, and apps inherit it"). This file checks the items the standard marks `check: ci-standard`:
|
|
3
|
+
// job-shape the managed ci.yml holds exactly the edition's jobs; the unit run is in `test`, the proof and the Codecov upload in `coverage` (the one OIDC job)
|
|
4
|
+
// full-history-base-ref every actions/checkout fetches the full history; a job that lints writes STARCI_BASE_REF before it lints
|
|
5
|
+
// heap-per-step every heavy step (heap.steps) declares NODE_OPTIONS --max-old-space-size >= heap.minMiB on the step
|
|
6
|
+
// sonar-lint-per-side a lint with --sonar names --side
|
|
7
|
+
// optional-providers an optional (continue-on-error) Codecov or Sonar step follows its availability probe in the same job
|
|
8
|
+
// test-sharding the test job is a shard matrix; each shard runs --shard=i/N without the per-file threshold; the coverage job merges, then proves
|
|
9
|
+
// lint-parallel the lint job is a matrix over side [be, fe]
|
|
10
|
+
// cache-saved-always no cache keyed by the commit sha; actions/cache/save runs under if: always()
|
|
11
|
+
// timeouts every job declares timeout-minutes
|
|
12
|
+
// sha-pinned-actions every remote `uses:` names a 40-hex commit sha (docker:// a sha256 digest; ./ local actions allowed)
|
|
13
|
+
// `ciStandardFindings({doc, file, scope})` is the pure core: scope `app-ci` (an app's managed .github/workflows/ci.yml: the example apps and every app through the
|
|
14
|
+
// app check) or `runtime` (the runtime repository's own workflows, scripts/hfs/runtime-rules/runtime-ci-standard.mjs), each checking the items whose `covers` names it.
|
|
15
|
+
import fs from 'node:fs';
|
|
16
|
+
import { parseYaml } from '../../../engine/yaml.mjs';
|
|
17
|
+
import { parseWorkflow } from '../runtime-rules/workflow-source.mjs';
|
|
18
|
+
import { found } from './read.mjs';
|
|
19
|
+
import { appWorkflowFindings } from './app-workflow-findings.mjs';
|
|
20
|
+
import { isPlainObject as record } from '../../../engine/plain-object.mjs';
|
|
21
|
+
import { byCodeUnit } from '../../lib/list.mjs';
|
|
22
|
+
|
|
23
|
+
export const CI_STANDARD = 'CI_STANDARD';
|
|
24
|
+
const STANDARD_FILE = new URL('../../../knowledge/hfs/ci-standard.yaml', import.meta.url);
|
|
25
|
+
const APP_CI = '.github/workflows/ci.yml';
|
|
26
|
+
const SHA_PIN = /@[0-9a-f]{40}$/;
|
|
27
|
+
const DIGEST_PIN = /@sha256:[0-9a-f]{64}$/;
|
|
28
|
+
|
|
29
|
+
let cached = null;
|
|
30
|
+
/** The standard (knowledge/hfs/ci-standard.yaml), read once. */
|
|
31
|
+
function ciStandard() {
|
|
32
|
+
if (!cached) {
|
|
33
|
+
const doc = parseYaml(fs.readFileSync(STANDARD_FILE, 'utf8'));
|
|
34
|
+
if (doc?.schema !== 'starci/ci-standard@1' || !Array.isArray(doc.items)) throw new Error('knowledge/hfs/ci-standard.yaml is not a starci/ci-standard@1 standard');
|
|
35
|
+
cached = doc;
|
|
36
|
+
}
|
|
37
|
+
return cached;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const jobsOf = (doc) => (record(doc?.jobs) ? Object.entries(doc.jobs).filter(([, job]) => record(job)) : []);
|
|
41
|
+
const stepsOf = (job) => (Array.isArray(job.steps) ? job.steps.filter(record) : []);
|
|
42
|
+
const usesOf = (step) => String(step.uses ?? '').trim();
|
|
43
|
+
const runOf = (step) => (typeof step.run === 'string' ? step.run : '');
|
|
44
|
+
|
|
45
|
+
/** The items of the standard this file checks for `scope`: their ids. */
|
|
46
|
+
const checkedItems = (standard, scope) => new Set(standard.items.filter((item) => item.check === 'ci-standard' && item.covers.includes(scope)).map((item) => item.id));
|
|
47
|
+
|
|
48
|
+
function jobShape(doc, edition, standard, say) {
|
|
49
|
+
const shape = standard.jobs[edition] ?? standard.jobs.full;
|
|
50
|
+
const ids = jobsOf(doc).map(([id]) => id);
|
|
51
|
+
const out = [];
|
|
52
|
+
const missing = shape.required.filter((id) => !ids.includes(id));
|
|
53
|
+
const extra = ids.filter((id) => !shape.required.includes(id) && !shape.optional.includes(id));
|
|
54
|
+
const gaps = [missing.length ? 'missing ' + missing.join(', ') : '', extra.length ? 'not in the standard ' + extra.join(', ') : ''].filter(Boolean).join('; ');
|
|
55
|
+
if (gaps) out.push(say('job-shape', `the jobs must be ${shape.required.join(', ')} (optional ${shape.optional.join(', ')}); ${gaps}`));
|
|
56
|
+
const withStep = (match) => jobsOf(doc).filter(([, job]) => stepsOf(job).some((step) => match(step))).map(([id]) => id);
|
|
57
|
+
const unit = withStep((step) => step.name === 'unit');
|
|
58
|
+
const upload = withStep((step) => usesOf(step).startsWith('codecov/codecov-action@'));
|
|
59
|
+
const proof = withStep((step) => /app check --coverage-from/.test(runOf(step)));
|
|
60
|
+
if (unit.join() !== 'test') out.push(say('job-shape', `the unit run must be a step of the test job alone, not of ${unit.join(', ') || 'no job'}`));
|
|
61
|
+
if (proof.join() !== 'coverage') out.push(say('job-shape', `the coverage proof must be a step of the coverage job alone, not of ${proof.join(', ') || 'no job'}`));
|
|
62
|
+
if (upload.length && upload.join() !== 'coverage') out.push(say('job-shape', `the Codecov upload must be a step of the coverage job alone, not of ${upload.join(', ')}`));
|
|
63
|
+
const oidc = jobsOf(doc).filter(([, job]) => job.permissions?.['id-token'] === 'write').map(([id]) => id);
|
|
64
|
+
if (upload.length && oidc.join() !== 'coverage') out.push(say('job-shape', `only the coverage job holds the OIDC id-token (id-token: write), not ${oidc.join(', ') || 'no job'}`));
|
|
65
|
+
return out;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function testSharding(doc, say) {
|
|
69
|
+
const test = record(doc.jobs?.test) ? doc.jobs.test : null;
|
|
70
|
+
if (!test) return [];
|
|
71
|
+
const out = [];
|
|
72
|
+
const shards = test.strategy?.matrix?.shard;
|
|
73
|
+
if (!Array.isArray(shards) || !shards.length) out.push(say('test-sharding', 'the test job must be a matrix over shard (ci.jobs.test.shards)'));
|
|
74
|
+
const unit = stepsOf(test).find((step) => step.name === 'unit');
|
|
75
|
+
const total = /--shard=\$\{\{\s*matrix\.shard\s*\}\}\/(\d+)/.exec(unit ? runOf(unit) : '');
|
|
76
|
+
if (unit && !total) out.push(say('test-sharding', 'the unit run must pass --shard=${{ matrix.shard }}/<N>'));
|
|
77
|
+
if (total && Array.isArray(shards) && Number(total[1]) !== shards.length) out.push(say('test-sharding', `the unit run's shard total ${total[1]} must equal the ${shards.length} matrix shards`));
|
|
78
|
+
if (unit && !runOf(unit).includes('--coverageThreshold={}')) out.push(say('test-sharding', 'a shard measures without the per-file threshold (--coverageThreshold={}); the coverage job proves it on the merged report'));
|
|
79
|
+
const coverage = record(doc.jobs?.coverage) ? stepsOf(doc.jobs.coverage) : [];
|
|
80
|
+
const merge = coverage.findIndex((step) => /app coverage merge\b/.test(runOf(step)));
|
|
81
|
+
const proof = coverage.findIndex((step) => /app check --coverage-from/.test(runOf(step)));
|
|
82
|
+
if (merge < 0 || (proof >= 0 && merge > proof)) out.push(say('test-sharding', 'the coverage job must merge the shard reports (starci app coverage merge) before the coverage proof'));
|
|
83
|
+
return out;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function lintParallel(doc, say) {
|
|
87
|
+
const sides = record(doc.jobs?.lint) ? doc.jobs.lint.strategy?.matrix?.side : null;
|
|
88
|
+
return Array.isArray(sides) && sides.map(String).sort(byCodeUnit).join() === 'be,fe' ? [] : [say('lint-parallel', 'the lint job must be a matrix over side [be, fe]')];
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function cacheSavedAlways(doc, say) {
|
|
92
|
+
const out = [];
|
|
93
|
+
for (const [id, job] of jobsOf(doc)) {
|
|
94
|
+
for (const step of stepsOf(job)) {
|
|
95
|
+
const uses = usesOf(step);
|
|
96
|
+
if (/^actions\/cache(?:\/restore)?@/.test(uses) && String(step.with?.key ?? '').includes('github.sha')) out.push(say('cache-saved-always', `job ${id}: a cache keyed by the commit sha never warms; key it by the lockfile hash (and the run) with restore-keys`));
|
|
97
|
+
if (uses.startsWith('actions/cache/save@') && !/^(?:\$\{\{\s*)?always\(\)/.test(String(step.if ?? '').trim())) out.push(say('cache-saved-always', `job ${id}: actions/cache/save runs under if: always(), so a red run still warms the cache`));
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return out;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function fullHistory(doc, say) {
|
|
104
|
+
const out = [];
|
|
105
|
+
for (const [id, job] of jobsOf(doc)) {
|
|
106
|
+
for (const step of stepsOf(job)) {
|
|
107
|
+
if (usesOf(step).startsWith('actions/checkout@') && String(step.with?.['fetch-depth']) !== '0') out.push(say('full-history-base-ref', `job ${id}: actions/checkout must fetch the full history (fetch-depth: 0)`));
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
for (const [id, job] of jobsOf(doc)) {
|
|
111
|
+
const steps = stepsOf(job);
|
|
112
|
+
const base = steps.findIndex((step) => /STARCI_BASE_REF=.*>>\s*"?\$GITHUB_ENV"?/.test(runOf(step)));
|
|
113
|
+
const lint = steps.findIndex((step) => /\bnpm run lint\b|\bstarci app lint\b/.test(runOf(step)));
|
|
114
|
+
if (lint >= 0 && (base < 0 || base > lint)) out.push(say('full-history-base-ref', `job ${id} must write STARCI_BASE_REF to GITHUB_ENV before its first lint step`));
|
|
115
|
+
}
|
|
116
|
+
return out;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function heapPerStep(doc, standard, say) {
|
|
120
|
+
const out = [];
|
|
121
|
+
for (const [id, job] of jobsOf(doc)) {
|
|
122
|
+
for (const step of stepsOf(job)) {
|
|
123
|
+
const run = runOf(step);
|
|
124
|
+
if (!standard.heap.steps.some((needle) => run.includes(needle))) continue;
|
|
125
|
+
const heap = /--max-old-space-size=(\d+)/.exec(String(step.env?.NODE_OPTIONS ?? ''));
|
|
126
|
+
if (!heap || Number(heap[1]) < standard.heap.minMiB) out.push(say('heap-per-step', `job ${id}, step ${step.name ?? run.split('\n')[0].slice(0, 60)}: a heavy step declares NODE_OPTIONS --max-old-space-size=${standard.heap.minMiB} (or more) on the step`));
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return out;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function sonarLintPerSide(doc, say) {
|
|
133
|
+
const out = [];
|
|
134
|
+
for (const [id, job] of jobsOf(doc)) {
|
|
135
|
+
for (const step of stepsOf(job)) {
|
|
136
|
+
const run = runOf(step);
|
|
137
|
+
if (/\b(?:npm run lint|starci(?:\.mjs)?["']?\s+app lint|app lint)\b/.test(run) && /\s--sonar\b/.test(run) && !/\s--side\s+(?:be\b|fe\b|\$\{\{\s*matrix\.side\s*\}\})/.test(run)) {
|
|
138
|
+
out.push(say('sonar-lint-per-side', `job ${id}, step ${step.name ?? 'lint'}: a lint with a Sonar import must name its side (--side be|fe) and the reports are merged afterwards; a combined lint with Sonar breaks the standard`));
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return out;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function optionalProviders(doc, say) {
|
|
146
|
+
const out = [];
|
|
147
|
+
const probes = [['codecov/codecov-action@', 'codecov-availability'], ['SonarSource/sonarqube-scan-action@', 'sonar-availability']];
|
|
148
|
+
for (const [id, job] of jobsOf(doc)) {
|
|
149
|
+
const steps = stepsOf(job);
|
|
150
|
+
steps.forEach((step, index) => {
|
|
151
|
+
const probe = probes.find(([prefix]) => usesOf(step).startsWith(prefix));
|
|
152
|
+
if (!probe || step['continue-on-error'] !== true) return;
|
|
153
|
+
if (!steps.slice(0, index).some((earlier) => earlier.id === probe[1])) out.push(say('optional-providers', `job ${id}: the optional ${usesOf(step).split('@')[0]} step must follow its availability probe (id ${probe[1]})`));
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
return out;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function timeouts(doc, say) {
|
|
160
|
+
return jobsOf(doc).filter(([, job]) => !Number.isFinite(Number(job['timeout-minutes'])) || job['timeout-minutes'] === null || job['timeout-minutes'] === '')
|
|
161
|
+
.filter(([, job]) => job.uses === undefined).map(([id]) => say('timeouts', `job ${id} must declare timeout-minutes`));
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function shaPinned(doc, say) {
|
|
165
|
+
const out = [];
|
|
166
|
+
for (const [id, job] of jobsOf(doc)) {
|
|
167
|
+
for (const uses of [job.uses, ...stepsOf(job).map((step) => step.uses)].filter((value) => typeof value === 'string')) {
|
|
168
|
+
const ref = uses.trim();
|
|
169
|
+
if (ref.startsWith('./')) continue;
|
|
170
|
+
const pinned = ref.startsWith('docker://') ? DIGEST_PIN.test(ref) : SHA_PIN.test(ref);
|
|
171
|
+
if (!pinned) out.push(say('sha-pinned-actions', `job ${id}: ${ref} must be pinned to a commit sha (a docker:// action to a sha256 digest)`));
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return out;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** The R301 findings of one workflow document: scope `app-ci` (with the app's edition) or `runtime`. Pure over the document and the standard. */
|
|
178
|
+
export function ciStandardFindings({ doc, file, scope, edition = 'full', standard = ciStandard() }) {
|
|
179
|
+
if (!record(doc)) return [];
|
|
180
|
+
const items = checkedItems(standard, scope);
|
|
181
|
+
const say = (item, why) => found(CI_STANDARD, file, `${CI_STANDARD} ${file}: [${item}] ${why} (knowledge/hfs/ci-standard.yaml)`, { item });
|
|
182
|
+
const checks = {
|
|
183
|
+
'job-shape': () => jobShape(doc, edition, standard, say),
|
|
184
|
+
'full-history-base-ref': () => fullHistory(doc, say),
|
|
185
|
+
'heap-per-step': () => heapPerStep(doc, standard, say),
|
|
186
|
+
'sonar-lint-per-side': () => sonarLintPerSide(doc, say),
|
|
187
|
+
'optional-providers': () => optionalProviders(doc, say),
|
|
188
|
+
'test-sharding': () => testSharding(doc, say),
|
|
189
|
+
'lint-parallel': () => lintParallel(doc, say),
|
|
190
|
+
'cache-saved-always': () => cacheSavedAlways(doc, say),
|
|
191
|
+
timeouts: () => timeouts(doc, say),
|
|
192
|
+
'sha-pinned-actions': () => shaPinned(doc, say),
|
|
193
|
+
};
|
|
194
|
+
return Object.entries(checks).filter(([id]) => items.has(id)).flatMap(([, check]) => check());
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** R301 over an app: its managed .github/workflows/ci.yml (the app check, and the runtime check over every example app). */
|
|
198
|
+
export function ciStandardAppFindings(ctx) {
|
|
199
|
+
const edition = ctx.repo?.edition === 'lite' ? 'lite' : 'full';
|
|
200
|
+
return appWorkflowFindings({ ...ctx, inScope: (file) => file === APP_CI && (ctx.inScope ?? (() => true))(file) },
|
|
201
|
+
({ path: file, text }) => ciStandardFindings({ doc: parseWorkflow(text), file, scope: 'app-ci', edition }), { dedupe: true });
|
|
202
|
+
}
|
|
@@ -18,7 +18,7 @@ const countOf = (value) => /^\d+$/.test(value) && Number.isSafeInteger(Number(va
|
|
|
18
18
|
const slash = (value) => path.posix.normalize(String(value).replaceAll('\\', '/'));
|
|
19
19
|
|
|
20
20
|
const emptyRecord = (source) => ({ source, lines: new Map(), functions: new Map(), branches: new Map(), summary: {}, problems: [] });
|
|
21
|
-
const addHits = (map, key, hits) => map.set(key, Math.min(Number.MAX_SAFE_INTEGER, (map.get(key) ?? 0) + hits));
|
|
21
|
+
export const addHits = (map, key, hits) => map.set(key, Math.min(Number.MAX_SAFE_INTEGER, (map.get(key) ?? 0) + hits));
|
|
22
22
|
const parseProblem = (record, line, tag) => record.problems.push(`invalid ${tag} at lcov line ${line}`);
|
|
23
23
|
|
|
24
24
|
function lineData(record, value, line) {
|
|
@@ -68,18 +68,27 @@ function hasAgeIdentity(job, doc) {
|
|
|
68
68
|
return steps.some((step) => strings(step.with).some((value) => reference.test(value)) || (typeof step.run === 'string' && reference.test(executable(step.run))));
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
-
|
|
71
|
+
/** 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. */
|
|
72
|
+
const CANDIDATE_BRANCHES = 'release/candidate-*';
|
|
73
|
+
const pushFilterValid = (options) => record(options) && Object.keys(options).length > 0 && Object.keys(options).every((key) => key === 'tags' || key === 'branches')
|
|
74
|
+
&& (options.tags === undefined || (Array.isArray(options.tags) && options.tags.length === 1 && options.tags[0] === 'v*'))
|
|
75
|
+
&& (options.branches === undefined || (Array.isArray(options.branches) && options.branches.length === 1 && options.branches[0] === CANDIDATE_BRANCHES));
|
|
76
|
+
|
|
77
|
+
function triggerFindings(doc, refuse, file = '') {
|
|
72
78
|
const on = workflowEventOptions(doc.on);
|
|
73
79
|
if (!record(on) || !Object.keys(on).length) return [refuse(TRIGGER, 'declare release tags [v*] and/or workflow_dispatch')];
|
|
74
80
|
const findings = [];
|
|
81
|
+
if (/(^|\/)ci\.ya?ml$/.test(file) && !(record(on.push) && Array.isArray(on.push.branches) && on.push.branches.includes(CANDIDATE_BRANCHES))) {
|
|
82
|
+
findings.push(refuse(TRIGGER, `the release CI must run on push of branches ['${CANDIDATE_BRANCHES}']: starci app release push waits for that run (starci app sync renders it)`));
|
|
83
|
+
}
|
|
75
84
|
for (const [event, options] of Object.entries(on)) {
|
|
76
85
|
if (event === 'workflow_dispatch') continue;
|
|
77
86
|
if (event !== 'push') {
|
|
78
|
-
findings.push(refuse(TRIGGER, `trigger ${event} is forbidden; app CI starts only on release tags [v*] or workflow_dispatch`));
|
|
87
|
+
findings.push(refuse(TRIGGER, `trigger ${event} is forbidden; app CI starts only on release tags [v*], the release candidate branches or workflow_dispatch`));
|
|
79
88
|
continue;
|
|
80
89
|
}
|
|
81
|
-
if (!
|
|
82
|
-
findings.push(refuse(TRIGGER,
|
|
90
|
+
if (!pushFilterValid(options)) {
|
|
91
|
+
findings.push(refuse(TRIGGER, `push may contain only tags: [v*] and branches: ['${CANDIDATE_BRANCHES}']; other branch and path filters are forbidden`));
|
|
83
92
|
}
|
|
84
93
|
}
|
|
85
94
|
return findings;
|
|
@@ -197,7 +206,7 @@ function perWorkflow({ path: file, text }) {
|
|
|
197
206
|
const refuse = (code, message) => found(code, file, `${code} ${file}: ${message}`);
|
|
198
207
|
const doc = parseWorkflow(text);
|
|
199
208
|
if (!record(doc)) return [refuse(TRIGGER, 'the workflow must be valid YAML with a release-only on block'), ...(deployName(file) ? [refuse(DEPLOY, 'the deploy workflow must be valid YAML')] : [])];
|
|
200
|
-
return [...triggerFindings(doc, refuse), ...deploymentFindings(doc, file, refuse), ...toolFindings(doc, refuse)];
|
|
209
|
+
return [...triggerFindings(doc, refuse, file), ...deploymentFindings(doc, file, refuse), ...toolFindings(doc, refuse)];
|
|
201
210
|
}
|
|
202
211
|
|
|
203
212
|
/** Synchronous R251 findings over the tracked workflows of a lite/full app. */
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// A finding names the file, the line and the pattern, never the value.
|
|
8
8
|
import fs from 'node:fs';
|
|
9
9
|
import path from 'node:path';
|
|
10
|
-
import { FORBIDDEN_FILES,
|
|
10
|
+
import { FORBIDDEN_FILES, secretHitsInFile } from '../../lib/secret-patterns.mjs';
|
|
11
11
|
import { isSopsEnvelope } from '../../lib/sops-envelope.mjs';
|
|
12
12
|
import { found, readText } from './read.mjs';
|
|
13
13
|
|
|
@@ -34,20 +34,17 @@ export function untrackedSecretFindings({ repoRoot, files, repo }) {
|
|
|
34
34
|
* no sops envelope, or a line that matches a secret pattern. `text` is the file's text, or null when it cannot be read (binary, too
|
|
35
35
|
* large, absent). The whole-tree check reads the work tree and the commit hook (`starci app hygiene`) reads the staged blob: one judgement.
|
|
36
36
|
*/
|
|
37
|
-
export function secretFileFindings({ file, text }) {
|
|
37
|
+
export function secretFileFindings({ file, text, manifestText }) {
|
|
38
38
|
const being = FORBIDDEN_FILES.find((rule) => rule.test(file));
|
|
39
39
|
if (being) return [found(PLAINTEXT_SECRET, file, `${file} is a plaintext secret by being (${being.name}); a secret is committed only as a sops envelope at .starcistacks/<env>/secrets/<slug>.enc`, { pattern: being.name })];
|
|
40
40
|
if (text === null || text === undefined || NOT_SCANNED.test(file)) return [];
|
|
41
41
|
if (file.endsWith('.enc')) return isSopsEnvelope(text) ? [] : [found(PLAINTEXT_SECRET, file, `${file} is named like a sealed secret but is not a sops envelope; encrypt the value with sops and commit only the envelope`, { pattern: 'sops-not-envelope' })];
|
|
42
42
|
const findings = [];
|
|
43
|
-
const lines = text.split(/\r?\n/);
|
|
44
43
|
const seen = new Set();
|
|
45
|
-
for (const
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
findings.push(found(PLAINTEXT_SECRET, file, `${file}:${index + 1} holds a plaintext secret (${pattern}); seal it with sops at .starcistacks/<env>/secrets/<slug>.enc and read it by *_FILE`, { line: index + 1, pattern }));
|
|
50
|
-
}
|
|
44
|
+
for (const { line, pattern } of secretHitsInFile(file, text, { manifestText })) {
|
|
45
|
+
if (seen.has(pattern)) continue;
|
|
46
|
+
seen.add(pattern);
|
|
47
|
+
findings.push(found(PLAINTEXT_SECRET, file, `${file}:${line} holds a plaintext secret (${pattern}); seal it with sops at .starcistacks/<env>/secrets/<slug>.enc and read it by *_FILE`, { line, pattern }));
|
|
51
48
|
}
|
|
52
49
|
return findings;
|
|
53
50
|
}
|
|
@@ -62,7 +59,8 @@ export function secretFindings({ repoRoot, files, resolver }) {
|
|
|
62
59
|
findings.push(found(PLAINTEXT_SECRET, file, `${file} is a plaintext secret file tracked in ${slot.slot}${destination}`, { slot: slot.slot, goesTo: slot.goesTo }));
|
|
63
60
|
continue;
|
|
64
61
|
}
|
|
65
|
-
|
|
62
|
+
const manifestText = /(?:^|\/)README\.md$/.test(file) ? readText(repoRoot, path.posix.join(path.posix.dirname(file), '.starcistacks/application-stacks.yaml')) : undefined;
|
|
63
|
+
findings.push(...secretFileFindings({ file, text: readText(repoRoot, file), manifestText }));
|
|
66
64
|
}
|
|
67
65
|
return findings;
|
|
68
66
|
}
|
|
@@ -75,7 +75,7 @@ function expressions(value) {
|
|
|
75
75
|
}
|
|
76
76
|
|
|
77
77
|
/** Source markers travel inside parsed scalar expressions, preserving flow maps, quoted keys and repeated values without matching comments. */
|
|
78
|
-
function workflowContextFindings({ path: file, text }) {
|
|
78
|
+
export function workflowContextFindings({ path: file, text }) {
|
|
79
79
|
const marked = text.replace(/\$\{\{/g, (match, offset) => `${match} __hfs_context_line_${text.slice(0, offset).split('\n').length}__ `);
|
|
80
80
|
const findings = [];
|
|
81
81
|
function inspect(value, keys) {
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { parseYaml } from '../../engine/yaml.mjs';
|
|
2
|
+
|
|
1
3
|
// secret-patterns.mjs — the one list of secret shapes the runtime refuses to carry: the push secret scan
|
|
2
4
|
// (scripts/supervisor/push-mains.mjs scanDiff) refuses an outgoing range that adds one, and the typed-log
|
|
3
5
|
// redaction (scripts/kernel/typed-logs.mjs redactText) blanks the value before a log row is stored.
|
|
@@ -102,3 +104,78 @@ export function secretHits(file, text) {
|
|
|
102
104
|
}
|
|
103
105
|
return names;
|
|
104
106
|
}
|
|
107
|
+
|
|
108
|
+
const BADGE_BEGIN = '<!-- >>> starci app sync (managed block; do not edit) >>> -->';
|
|
109
|
+
const BADGE_END = '<!-- <<< starci app sync <<< -->';
|
|
110
|
+
|
|
111
|
+
/** Provider-shaped credentials remain refused even where the keyword heuristic admits public badge tokens. */
|
|
112
|
+
function blankBadgeValue(value) {
|
|
113
|
+
return secretHits('badge.mjs', value).length ? value : ' '.repeat(value.length);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Only scalar values at the two block-mapping service paths qualify; every other YAML substring remains scanned. */
|
|
117
|
+
function manifestBadgeText(text) {
|
|
118
|
+
try { parseYaml(text); } catch { return text; }
|
|
119
|
+
const parents = [];
|
|
120
|
+
return text.split('\n').map(line => {
|
|
121
|
+
if (/^\s*(?:#.*)?$/.test(line)) return line;
|
|
122
|
+
const indent = /^ */.exec(line)[0].length;
|
|
123
|
+
while (parents.length && parents.at(-1).indent >= indent) parents.pop();
|
|
124
|
+
const match = /^( *)(?:([A-Za-z0-9_-]+)|"([A-Za-z0-9_-]+)"|'([A-Za-z0-9_-]+)'):(.*)$/.exec(line);
|
|
125
|
+
if (!match) { parents.push({ indent, key: null }); return line; }
|
|
126
|
+
match[5] = match[5].trimStart();
|
|
127
|
+
const key = match[2] ?? match[3] ?? match[4];
|
|
128
|
+
const keys = [...parents.map(parent => parent.key), key].join('.');
|
|
129
|
+
const container = /^(?:#.*)?$/.test(match[5].trim());
|
|
130
|
+
const nestedScalar = parents.some(parent => parent.container === false);
|
|
131
|
+
parents.push({ indent, key, container });
|
|
132
|
+
if (nestedScalar) return line;
|
|
133
|
+
if (!['services.codecov.badgeToken', 'services.sonar.badgeToken'].includes(keys)) return line;
|
|
134
|
+
const scalar = /^(?:"([A-Za-z0-9_-]{6,128})"|'([A-Za-z0-9_-]{6,128})'|([A-Za-z0-9_-]{6,128}))(?=\s*(?:#|$))/.exec(match[5]);
|
|
135
|
+
if (!scalar) return line;
|
|
136
|
+
const start = line.length - match[5].length;
|
|
137
|
+
return line.slice(0, start) + blankBadgeValue(scalar[0]) + match[5].slice(scalar[0].length);
|
|
138
|
+
}).join('\n');
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Unreadable manifests admit only the default badge hosts; readable Sonar declarations select their own hosts. */
|
|
142
|
+
function badgeHosts(manifestText) {
|
|
143
|
+
const hosts = new Set(['codecov.io']);
|
|
144
|
+
let doc;
|
|
145
|
+
try { doc = parseYaml(manifestText); } catch { hosts.add('sonarcloud.io'); return hosts; }
|
|
146
|
+
const sonar = doc?.services?.sonar;
|
|
147
|
+
if (sonar?.provider === 'sonarcloud') hosts.add('sonarcloud.io');
|
|
148
|
+
else for (const host of [sonar?.host?.public, sonar?.host?.local]) {
|
|
149
|
+
try { hosts.add(new URL(host).host); } catch { /* Invalid hosts admit nothing. */ }
|
|
150
|
+
}
|
|
151
|
+
return hosts;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Only token query values in managed Markdown badge images on declared hosts are blanked; links and other parameters remain scanned. */
|
|
155
|
+
function readmeBadgeText(text, manifestText) {
|
|
156
|
+
const begin = text.indexOf(BADGE_BEGIN), end = text.indexOf(BADGE_END, begin + BADGE_BEGIN.length);
|
|
157
|
+
if (begin < 0 || end < 0) return text;
|
|
158
|
+
const hosts = badgeHosts(manifestText);
|
|
159
|
+
const start = begin + BADGE_BEGIN.length;
|
|
160
|
+
const block = text.slice(start, end).replace(/!\[[^\]\n]*\]\((https?:\/\/[^\s)]+)\)/g, (image, address) => {
|
|
161
|
+
let url;
|
|
162
|
+
try { url = new URL(address); } catch { return image; }
|
|
163
|
+
if (!hosts.has(url.host) || url.username || url.password) return image;
|
|
164
|
+
const badgePath = url.host === 'codecov.io' ? /\/graph\/badge\.svg$/.test(url.pathname) : url.pathname === '/api/project_badges/measure';
|
|
165
|
+
if (!badgePath) return image;
|
|
166
|
+
const fragment = address.indexOf('#');
|
|
167
|
+
const queryEnd = fragment < 0 ? address.length : fragment;
|
|
168
|
+
const safe = address.slice(0, queryEnd).replace(/([?&]token=)([^&#)\s]*)/g, (_, prefix, value) => prefix + blankBadgeValue(value));
|
|
169
|
+
return image.replace(address, () => safe + address.slice(queryEnd));
|
|
170
|
+
});
|
|
171
|
+
return text.slice(0, start) + block + text.slice(end);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Whole-file secret judgement exempts only declared public badge values and preserves original line numbers and every provider shape. */
|
|
175
|
+
export function secretHitsInFile(file, fullText, { manifestText } = {}) {
|
|
176
|
+
const normalized = file.replace(/\\/g, '/');
|
|
177
|
+
let text = fullText;
|
|
178
|
+
if (/(?:^|\/)\.starcistacks\/application-stacks\.yaml$/.test(normalized)) text = manifestBadgeText(text);
|
|
179
|
+
if (/(?:^|\/)README\.md$/.test(normalized)) text = readmeBadgeText(text, manifestText);
|
|
180
|
+
return text.split(/\r?\n/).flatMap((line, index) => secretHits(file, line).map(pattern => ({ line: index + 1, pattern })));
|
|
181
|
+
}
|
package/scaffold/app.mjs
CHANGED
|
@@ -222,7 +222,7 @@ const FE_PACKAGES = Object.freeze({
|
|
|
222
222
|
const PACKAGE_WORKSPACE_SCRIPTS = Object.freeze({ build: 'tsc -p tsconfig.build.json', lint: WORKSPACE_LINT, typecheck: 'tsc --noEmit -p tsconfig.json' });
|
|
223
223
|
|
|
224
224
|
/** The app hfs.json of a new app called `name`; full remains the byte-for-byte default declaration. */
|
|
225
|
-
const starterDeclaration = (name, manifest = loadSlotManifest(), edition = 'full') => ({
|
|
225
|
+
export const starterDeclaration = (name, manifest = loadSlotManifest(), edition = 'full') => ({
|
|
226
226
|
hfs: manifest.major,
|
|
227
227
|
kind: 'app',
|
|
228
228
|
...(edition === 'full' ? {} : { edition }),
|
|
@@ -300,9 +300,10 @@ export function peerDependencies(pkg, pins, pairs = parseYaml(fs.readFileSync(PE
|
|
|
300
300
|
/**
|
|
301
301
|
* The fe-stack core packages one version of which the whole install resolves (npm `overrides`, at their canon pins): a
|
|
302
302
|
* transitive dependency otherwise brings its own (zustand 4 under @xyflow/react, dayjs under typeorm, a hoisted newer zod
|
|
303
|
-
* that breaks the zodResolver types), and @xyflow/react itself stays the grammar's one version.
|
|
303
|
+
* that breaks the zodResolver types), and @xyflow/react itself stays the grammar's one version. @graphql-tools/utils is the one security override: @nestjs/graphql 13.x
|
|
304
|
+
* pulls <=12.0.0 (GHSA-7mx3-vvmw-hjmv), and the pin's fixed 12.x closes the advisory until @nestjs/graphql 14 is adopted (then drop it; canon-pins.yaml says why).
|
|
304
305
|
*/
|
|
305
|
-
const ROOT_OVERRIDES = Object.freeze(['@xyflow/react', 'dayjs', 'zod', 'zustand']);
|
|
306
|
+
const ROOT_OVERRIDES = Object.freeze(['@graphql-tools/utils', '@xyflow/react', 'dayjs', 'zod', 'zustand']);
|
|
306
307
|
|
|
307
308
|
/**
|
|
308
309
|
* The root package.json of a new app, the monorepo root: name, workspaces, npm, its edition's dependencies with their runtime
|
|
@@ -493,7 +494,7 @@ export function scaffoldWorkSeeds(project) {
|
|
|
493
494
|
* be side (once per product app of the fe side, coreFeApps), the be `apps/cli/` folder once when the cli app is declared, an fe `apps/<name>/` folder
|
|
494
495
|
* once for the declared fe app <name> (none other), `__project__` in a path replaced by the project name.
|
|
495
496
|
*/
|
|
496
|
-
function skeletonOf(scope, app, vars) {
|
|
497
|
+
export function skeletonOf(scope, app, vars) {
|
|
497
498
|
const files = app.edition === 'lite'
|
|
498
499
|
? [...skeletonDirectory(scope, 'skeleton', app, vars, rel => liteBaseFile(scope, rel)), ...skeletonDirectory(scope, 'skeleton-lite', app, vars, rel => liteOverlayFile(scope, rel))]
|
|
499
500
|
: skeletonDirectory(scope, 'skeleton', app, vars);
|
package/src/main.mjs
CHANGED
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
// starci app sync (--check | --write) [--cwd <dir>] the managed files of the root (scripts, husky, CI, .gitignore block, sonar, prettier) and
|
|
30
30
|
// of each side (tsconfig, eslint, jest, stylelint); sync/cli.mjs
|
|
31
31
|
// starci app hygiene the pre-commit guard: staged .starciwork and .starcistacks paths, and the secrets guard over every staged file (read from the index); sync/cli.mjs
|
|
32
|
+
// starci app coverage merge --out <lcov> <lcov-or-dir>... the lcov reports of the sharded unit run merged into one (coverage/cli.mjs; the managed CI coverage job)
|
|
32
33
|
// starci app new service <dir> <name> [--inject <Decorator>=<module>:<Type> | <Class>=<module>]... [--cwd <dir>]
|
|
33
34
|
// a back-end `<name>.service.ts` and its `<name>.service.spec.ts` skeleton (scaffold/service.mjs; <dir> is
|
|
34
35
|
// relative to be/): the spec is built
|
|
@@ -89,7 +90,7 @@ import { dbPostCutoverMain } from '../db/post-cutover.mjs';
|
|
|
89
90
|
import { dbDumpMain } from '../db/dump.mjs';
|
|
90
91
|
import { dbResetMain } from '../db/reset.mjs';
|
|
91
92
|
|
|
92
|
-
export const VERBS = Object.freeze(['scaffold', 'add', 'lint', 'sync', 'check', 'upgrade', 'stack', 'release', 'explain', 'emit', 'new', 'secret', 'ci', 'db', 'hygiene']);
|
|
93
|
+
export const VERBS = Object.freeze(['scaffold', 'add', 'lint', 'sync', 'check', 'upgrade', 'stack', 'release', 'explain', 'emit', 'new', 'secret', 'ci', 'db', 'hygiene', 'coverage']);
|
|
93
94
|
const DATABASE_VERBS = new Map([['baseline', dbBaselineMain], ['restamp', dbRestampMain], ['plan', dbPlanMain], ['migrate', dbMigrateMain], ['post-cutover', dbPostCutoverMain], ['dump', dbDumpMain], ['reset', dbResetMain]]);
|
|
94
95
|
|
|
95
96
|
const USAGE = `starci app scaffold <name> [--into <dir>] [--edition full|lite] [--registry-visibility public|private] [--cwd <dir>]
|
|
@@ -128,6 +129,7 @@ starci app ci var set <NAME> [--env E] [--value v] [--repo owner/name] [--cwd <d
|
|
|
128
129
|
starci app ci var sync [--execute] [--repo owner/name] [--cwd <dir>]
|
|
129
130
|
starci app ci verify [--json] [--repo owner/name] [--cwd <dir>]
|
|
130
131
|
starci app hygiene [--cwd <dir>]
|
|
132
|
+
starci app coverage merge --out <lcov> <lcov-or-dir>... [--cwd <dir>]
|
|
131
133
|
`;
|
|
132
134
|
const PER_CODE_LIMIT = 25;
|
|
133
135
|
const VALUE_FLAGS = new Set(['--base', '--inject', '--into', '--event', '--from', '--service', '--connection', '--owner', '--failed', '--done', '--coverage-from', '--registry-visibility']);
|
|
@@ -419,7 +421,7 @@ async function stackAppMain(argv, { cwd, edition, stackMain, stdout: out, stderr
|
|
|
419
421
|
return run(argv, { cwd, out, err });
|
|
420
422
|
}
|
|
421
423
|
|
|
422
|
-
const DIRECT_VERBS = new Map([['ci', async (argv, io) => (await import('../ci/cli.mjs')).ciMain(argv, io)], ['lint', lintMain], ['secret', secretMain], ['db', databaseMain], ['upgrade', upgradeAppMain], ['stack', stackAppMain], ['release', releaseMain]]);
|
|
424
|
+
const DIRECT_VERBS = new Map([['ci', async (argv, io) => (await import('../ci/cli.mjs')).ciMain(argv, io)], ['lint', lintMain], ['secret', secretMain], ['db', databaseMain], ['upgrade', upgradeAppMain], ['stack', stackAppMain], ['release', releaseMain], ['coverage', async (argv, io) => (await import('../coverage/cli.mjs')).coverageMain(argv, io)]]);
|
|
423
425
|
const PARSED_VERBS = new Map([['check', checkMain], ['emit', emitMain], ['new', newMain], ['add', addMain], ['scaffold', scaffoldMain], ['explain', explainMain]]);
|
|
424
426
|
|
|
425
427
|
function dispatchMain(verb, argv, io) {
|