@starci/hfs 4.7.12 → 4.7.13
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 +5 -0
- 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/scripts/hfs/architecture/canon-clones.mjs +1 -1
- 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/stack-tooling.mjs +5 -2
- package/runtime/scripts/hfs/rules/stacks-swarm.mjs +5 -5
- 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 +5 -2
- package/runtime/scripts/lib/env.mjs +2 -1
- 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/sync/ci-commands.mjs +275 -275
- package/templates/app/ci-workflows-lite/github/workflows/ci.yml +2 -0
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// lint-report.mjs - the external issues report the release gate's sonar row produces before it runs the scanner.
|
|
2
|
+
// The app declares `sonar.externalIssuesReportPaths=reports/lint.sonar.json`; the managed CI makes it with `lint --side <side> --sonar reports/lint.<side>.sonar.json`
|
|
3
|
+
// per lint matrix side and a "merge Sonar lint reports" step. This module does the same locally, through the same process seam as every gate
|
|
4
|
+
// (an argument array through ctx.spawn, never a shell). The merge below is the one in packages/hfs/sync/ci-commands.mjs (sonarMergeSteps): keep them identical.
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { firstLine, tool } from '../stack/context.mjs';
|
|
8
|
+
|
|
9
|
+
const LINT_SIDES = ['be', 'fe'];
|
|
10
|
+
const MERGED_REPORT = 'reports/lint.sonar.json';
|
|
11
|
+
const sideReport = (side) => `reports/lint.${side}.sonar.json`;
|
|
12
|
+
|
|
13
|
+
/** CI merge: rules unique by id (last one wins, first position kept), issues concatenated in side order. */
|
|
14
|
+
function mergeSonarReports(reports) {
|
|
15
|
+
const rules = [...new Map(reports.flatMap((report) => report.rules).map((rule) => [rule.id, rule])).values()];
|
|
16
|
+
const issues = reports.flatMap((report) => report.issues);
|
|
17
|
+
return { rules, issues };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** The sides the app has: a be/ or fe/ folder beside the app root. */
|
|
21
|
+
const sidesOf = (root) => LINT_SIDES.filter((side) => fs.existsSync(path.join(root, side)));
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Runs `starci app lint --side <side> --sonar <file>` for every side the app has, then writes reports/lint.sonar.json.
|
|
25
|
+
* `launch` turns an argv into [command, args] for the platform. Returns { pass, text }: the honest log of every lint run and the verdict.
|
|
26
|
+
* A lint that exits non-zero (findings or error) or writes no report fails the row; nothing is skipped.
|
|
27
|
+
*/
|
|
28
|
+
export function produceLintReports(ctx, { launch, platform, env }) {
|
|
29
|
+
const sides = sidesOf(ctx.cwd);
|
|
30
|
+
const out = [];
|
|
31
|
+
if (sides.length === 0) return { pass: false, text: `no be/ or fe/ side in ${ctx.cwd}: cannot produce ${MERGED_REPORT}\n` };
|
|
32
|
+
fs.mkdirSync(path.join(ctx.cwd, 'reports'), { recursive: true });
|
|
33
|
+
for (const side of sides) {
|
|
34
|
+
const file = path.join(ctx.cwd, sideReport(side));
|
|
35
|
+
fs.rmSync(file, { force: true });
|
|
36
|
+
const argv = ['npx', 'starci', 'app', 'lint', '--side', side, '--sonar', sideReport(side)];
|
|
37
|
+
const [command, args] = launch(argv, platform);
|
|
38
|
+
out.push(`$ ${argv.join(' ')}\n`);
|
|
39
|
+
const run = tool(ctx, command, args, { cwd: ctx.cwd, env });
|
|
40
|
+
out.push(`${run.stdout ?? ''}${run.stderr ?? ''}`);
|
|
41
|
+
if (run.error) return { pass: false, text: `${out.join('')}\nlint ${side} cannot run (${run.error.message})\n` };
|
|
42
|
+
if (run.status !== 0) return { pass: false, text: `${out.join('')}\nlint ${side} failed: exit ${run.status}; the scanner was not run\n` };
|
|
43
|
+
if (!fs.existsSync(file)) return { pass: false, text: `${out.join('')}\nlint ${side} wrote no ${sideReport(side)}; the scanner was not run\n` };
|
|
44
|
+
}
|
|
45
|
+
try {
|
|
46
|
+
const reports = sides.map((side) => JSON.parse(fs.readFileSync(path.join(ctx.cwd, sideReport(side)), 'utf8')));
|
|
47
|
+
fs.writeFileSync(path.join(ctx.cwd, MERGED_REPORT), `${JSON.stringify(mergeSonarReports(reports), null, 2)}\n`);
|
|
48
|
+
} catch (error) {
|
|
49
|
+
return { pass: false, text: `${out.join('')}\ncannot merge the side reports: ${firstLine(error.message)}\n` };
|
|
50
|
+
}
|
|
51
|
+
return { pass: true, text: `${out.join('')}\nwrote ${MERGED_REPORT} from ${sides.map(sideReport).join(', ')}\n\n` };
|
|
52
|
+
}
|
|
@@ -29,14 +29,14 @@ pins:
|
|
|
29
29
|
source: packages/grammar/package.json
|
|
30
30
|
why: 'the grammar front ends read: the brand layer sets `--font-sans` and `--font-mono` and the grammar reads them; it carries the Input tel kind, the copyable and read-only Input, the IconButton disclosure props and count badge, ChatBubble, FillViewport, Textarea mentions and Enter-to-send, the leading PrimaryRailLayout rail, the plate-less Image, ListRow, the soft Alert, Progress steps, the floating primary IconButton, FlowGraph, the STATE-8 loading views and the bordered selectable TagGroup chips. The runtime source version wins for a @starci package.'
|
|
31
31
|
'@starci/eslint-canon-be':
|
|
32
|
-
version: 3.4.
|
|
32
|
+
version: 3.4.20
|
|
33
33
|
group: starci
|
|
34
34
|
install: registry
|
|
35
35
|
side: be
|
|
36
36
|
source: packages/eslint/be/package.json
|
|
37
37
|
why: 'the back-end ESLint canon: `loadHfs(import.meta.url)` of be/eslint.config.mjs finds the app-root hfs.json (kind app) and gives every linted file the view of the be side, and the project graph is built per side; its bundled runtime copies (slots, canon-pins, failure codes, the architecture machine) put .starcistacks and .sops.yaml at the app root; `starciBeConfig({ hfs: loadHfs(import.meta.url) })` is the typed factory and the BE-CONVENTION laws are its rules.'
|
|
38
38
|
'@starci/eslint-canon-fe':
|
|
39
|
-
version: 8.4.
|
|
39
|
+
version: 8.4.19
|
|
40
40
|
group: starci
|
|
41
41
|
install: registry
|
|
42
42
|
side: fe
|
|
@@ -76,14 +76,14 @@ pins:
|
|
|
76
76
|
source: packages/test-world/package.json
|
|
77
77
|
why: 'the shared e2e library of every back end (R47, R48), a devDependency of every back end: the warm stack behind toxiproxy, the network-edge fakes, the Nest boot, the typed useTestWorld handle, useSandbox for contract specs and the outage lock. Kafka is real own infra (the one digest-pinned apache/kafka KRaft image, a broker listener and proxy per slot, slot-prefixed topics, consumer groups and client ids) and Postgres is schema-per-context (a schema and a login role per context in a shared database). Each worker has its own data slot (<ns>_w<k>) with its own databases, Keycloak realm, leased Redis DB, bucket/collection/topic prefixes, fakes host, toxiproxy proxies and outage lock; a run provisions min(jest workers, workers cap, default 2) slots, and state file protocol 2 pairs exactly with @starci/jest-preset (TEST_WORLD_PAIR_MISMATCH otherwise). A modules world boots real peer apps beside its modules, world.apps.<name>.during(fn) is the outage of a peer app, w.keycloak.clientSecret(client) answers the run-generated secret of a confidential realm client, world.keycloak.events/sessions read the realm user events and live sessions, world.infra.postgresql.connection(name) takes one database connection down while the others serve, and world.resolve and scope.resolve take any Nest token. Every path a declaration names (`stack`, seeds, the realm, Dockerfiles) resolves from the app root (the directory of hfs.json); the globalSetup registers the path aliases as TypeScript resolves them. Its CLI main is exported at ./cli for starci app stack.'
|
|
78
78
|
'@starci/cli':
|
|
79
|
-
version: 1.7.
|
|
79
|
+
version: 1.7.13
|
|
80
80
|
group: starci
|
|
81
81
|
install: registry
|
|
82
82
|
side: both
|
|
83
83
|
source: packages/cli/package.json
|
|
84
84
|
why: 'the only StarCi command package; product repositories install it for starci app, while @starci/hfs remains its pinned transitive implementation.'
|
|
85
85
|
'@starci/hfs':
|
|
86
|
-
version: 4.7.
|
|
86
|
+
version: 4.7.13
|
|
87
87
|
group: starci
|
|
88
88
|
install: registry
|
|
89
89
|
side: both
|
|
@@ -27,7 +27,7 @@ defaults:
|
|
|
27
27
|
# The steps that need a raised Node heap on the GitHub runner, matched on their `run` text, and the least heap they must declare in NODE_OPTIONS.
|
|
28
28
|
heap:
|
|
29
29
|
minMiB: 6144
|
|
30
|
-
steps: ['npm run typecheck', 'npm run lint', 'app lint', 'app check --coverage-from']
|
|
30
|
+
steps: ['npm run typecheck', 'npm run lint', 'app lint', 'app check --coverage-from', 'npm run db:lint']
|
|
31
31
|
|
|
32
32
|
items:
|
|
33
33
|
- id: candidate-trigger
|
|
@@ -106,7 +106,7 @@ function queueMethods(env, declaration, constructor, excluded) {
|
|
|
106
106
|
&& decoratorsHave(env, parameter, 'InjectQueueOutbox', '@modules/platform/queue'));
|
|
107
107
|
if (!outbox || !env.ts.isIdentifier(outbox.name)) return;
|
|
108
108
|
for (const method of declaration.members) {
|
|
109
|
-
if (!env.ts.isMethodDeclaration(method) ||
|
|
109
|
+
if (!env.ts.isMethodDeclaration(method) || method.body?.statements.length !== 1) continue;
|
|
110
110
|
const call = statementCall(env.ts, method.body.statements[0]);
|
|
111
111
|
if (call && env.ts.isCallExpression(call) && receiverMember(env.ts, call) === outbox.name.text && call.expression.name.text === 'write'
|
|
112
112
|
&& call.arguments.length === 3 && call.arguments.every(argument => mapping(env, argument))) excluded.add(method);
|
|
@@ -62,11 +62,13 @@ function catalogsPerApp(config, graph) {
|
|
|
62
62
|
|
|
63
63
|
/** Each interpolation can fill part or all of a segment; the template's static fragments keep their literal spelling. */
|
|
64
64
|
function templatePatternOf(node) {
|
|
65
|
-
|
|
66
|
-
|
|
65
|
+
const pattern = [];
|
|
66
|
+
for (const segment of [node.head.text, ...node.templateSpans.map(span => span.literal.text)].join('\u0000').split('.')) {
|
|
67
|
+
if (!segment.includes('\u0000')) { pattern.push(segment); continue; }
|
|
67
68
|
const parts = segment.split('\u0000');
|
|
68
|
-
|
|
69
|
-
}
|
|
69
|
+
pattern.push(parts.every(part => part === '') ? '*' : new RegExp(`^${parts.map((part) => escapeRegExp(part)).join('.*')}$`, 'su'));
|
|
70
|
+
}
|
|
71
|
+
return pattern;
|
|
70
72
|
}
|
|
71
73
|
|
|
72
74
|
function patternsOf(ts, files) {
|
|
@@ -94,7 +94,7 @@ function moduleSpecifierOf(ts, node) {
|
|
|
94
94
|
function isApolloClientConstruction(ts, kit, checker, node) {
|
|
95
95
|
if (!ts.isNewExpression(node)) return false;
|
|
96
96
|
const binding = kit.importBinding(checker, node.expression);
|
|
97
|
-
return Boolean(binding
|
|
97
|
+
return Boolean(binding?.name === 'ApolloClient' && binding.module.startsWith('@apollo/client'));
|
|
98
98
|
}
|
|
99
99
|
|
|
100
100
|
function inspectLibraryImport({ ts, file, node, transport, report }) {
|
|
@@ -60,7 +60,10 @@ export function qualityServices(doc, { example = false } = {}) {
|
|
|
60
60
|
/** Enumerate a missing directory as empty; all filesystem access uses the caller's injected synchronous seam. */
|
|
61
61
|
function entries(dir, fs) {
|
|
62
62
|
try { return fs.readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name)); }
|
|
63
|
-
catch (error) {
|
|
63
|
+
catch (error) {
|
|
64
|
+
if (error.code === 'ENOENT') return [];
|
|
65
|
+
throw error;
|
|
66
|
+
}
|
|
64
67
|
}
|
|
65
68
|
|
|
66
69
|
/** Read only encrypted envelope key names, never decrypting or returning any sealed value. */
|
|
@@ -70,7 +73,7 @@ export function sealedKeyNames(root, { fs }) {
|
|
|
70
73
|
for (const member of entries(path.join(base, env.name, 'secrets'), fs).filter((entry) => entry.isFile() && entry.name.endsWith('.enc'))) {
|
|
71
74
|
const file = `.starcistacks/${env.name}/secrets/${member.name}`;
|
|
72
75
|
const source = fs.readFileSync(path.join(root, file), 'utf8');
|
|
73
|
-
const dotenv = [...source.matchAll(/^([A-Za-z_]
|
|
76
|
+
const dotenv = [...source.matchAll(/^([A-Za-z_]\w*)=ENC\[/gm)].map((match) => match[1]).filter((key) => !key.startsWith('sops_'));
|
|
74
77
|
let keys = dotenv;
|
|
75
78
|
if (!dotenv.length) {
|
|
76
79
|
const doc = parseYaml(source);
|
|
@@ -87,7 +87,7 @@ function baselinePlan(root, opts, now) {
|
|
|
87
87
|
if (!active.length) throw new DbVerbError('baseline requires an existing active migration chain');
|
|
88
88
|
const archived = dbSqlFiles(root, MIGRATIONS_ARCHIVE_DIR);
|
|
89
89
|
const oldStamps = [...active, ...archived].map((entry) => entry.stamp);
|
|
90
|
-
const last = oldStamps.
|
|
90
|
+
const last = oldStamps.toSorted((left, right) => left.localeCompare(right)).at(-1);
|
|
91
91
|
const stamp = opts.stamp ?? now().toISOString().replace(/\D/g, '').slice(0, 14);
|
|
92
92
|
if (!/^\d{14}$/.test(stamp) || migrationStamp(stamp) === null || stamp <= last) throw new DbVerbError('--stamp must be a valid UTC version after the archive boundary');
|
|
93
93
|
const file = `${MIGRATIONS_DIR}/${stamp}_baseline.sql`;
|
|
@@ -225,7 +225,10 @@ function extensionDeclaration(stmt, sql, extensions) {
|
|
|
225
225
|
|
|
226
226
|
function baselineStatement(state, stmt, sql) {
|
|
227
227
|
if (roleDeclaration(stmt, sql, state.roles) || extensionDeclaration(stmt, sql, state.extensions)) return;
|
|
228
|
-
if (stmt.DropRoleStmt) {
|
|
228
|
+
if (stmt.DropRoleStmt) {
|
|
229
|
+
for (const role of stmt.DropRoleStmt.roles ?? []) state.roles.delete(role.RoleSpec?.rolename);
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
229
232
|
if (stmt.GrantRoleStmt || stmt.AlterExtensionStmt || stmt.AlterExtensionContentsStmt || /^OBJECT_ROLE$/.test(stmt.RenameStmt?.renameType ?? '')) throw new DbVerbError('baseline cannot reconstruct role membership, role renames or extension alterations');
|
|
230
233
|
if (dataMutation(stmt) || sourceExecution(stmt)) throw new DbVerbError('baseline cannot reconstruct migration-owned data changes or custom execution from a schema-only dump');
|
|
231
234
|
const node = Object.values(stmt)[0];
|
|
@@ -29,7 +29,8 @@ const inner = (t) => t.replace(/^<|>$/g, '');
|
|
|
29
29
|
|
|
30
30
|
// ---- extraction -----------------------------------------------------------------------------------------------
|
|
31
31
|
|
|
32
|
-
const
|
|
32
|
+
const COMMAND_FORM = /npx\s+starci|npm\s+run\s+starci(?:\s+--silent)?(?:\s+--)?|starci/;
|
|
33
|
+
const COMMAND_AT = new RegExp(String.raw`(?<![\w@./:-])(?:${COMMAND_FORM.source})(?![\w@./-])(?=\s|$)`, 'g');
|
|
33
34
|
const FENCE = /^\s*(?:[-*>|]\s*)?```/;
|
|
34
35
|
const SCRIPT_COMMENT = /^\s*(?:\/\/|\*|\/\*)/;
|
|
35
36
|
const CONTINUED = /\\\s*$/;
|
|
@@ -21,7 +21,10 @@ const RULE_KEYS = new Set(['id', 'code', 'law', 'scope', 'kinds', 'gates', 'fail
|
|
|
21
21
|
const CASE_KEYS = new Set(['id', 'date', 'source', 'ruling', 'quote', 'supersedes', 'status', 'staleWhen', 'amendedBy']);
|
|
22
22
|
const CASE_DATE = /^\d{4}-\d{2}-\d{2}$/;
|
|
23
23
|
const ENFORCER_KEYS = new Set(['kind', 'id', 'status', 'at', 'editions', 'editionWhy']);
|
|
24
|
-
const
|
|
24
|
+
const CASE_ID = /case-[1-9]\d*/;
|
|
25
|
+
const RULING_REFERENCE = new RegExp(String.raw`(?:ruling:)?R\d{2,3}(?:/${CASE_ID.source})?`);
|
|
26
|
+
const OWNER_REFERENCE = /owner:[a-z][a-z0-9]*(?:-[a-z0-9]+)*/;
|
|
27
|
+
const CASE_REFERENCE = new RegExp(`^(?:${CASE_ID.source}|${RULING_REFERENCE.source}|${OWNER_REFERENCE.source})$`);
|
|
25
28
|
const EVIDENCE_KEYS = new Set(['path', 'edition', 'editions']);
|
|
26
29
|
const PROOF_KEYS = new Set([...EVIDENCE_KEYS, 'codes', 'fixtures']);
|
|
27
30
|
const WITNESS_KEYS = new Set(['edition', 'base', 'mutate', 'expect']);
|
|
@@ -191,7 +194,7 @@ function checkRuleCases(rule, label, bad) {
|
|
|
191
194
|
const at = `${label}.cases[${index}]`;
|
|
192
195
|
if (!isPlainObject(item)) { bad.push(`${at} is not a map`); return; }
|
|
193
196
|
for (const key of Object.keys(item)) if (!CASE_KEYS.has(key)) bad.push(`${at} has unknown key ${key}`);
|
|
194
|
-
if (typeof item.id !== 'string' || !/^case-[1-9]
|
|
197
|
+
if (typeof item.id !== 'string' || !/^case-[1-9]\d*$/.test(item.id) || ids.has(item.id)) bad.push(`${at}.id must be a unique case-N`);
|
|
195
198
|
ids.add(item.id);
|
|
196
199
|
if (typeof item.date !== 'string' || !CASE_DATE.test(item.date)) bad.push(`${at}.date must be YYYY-MM-DD`);
|
|
197
200
|
if (item.source !== 'owner') bad.push(`${at}.source must be owner`);
|
|
@@ -12,7 +12,6 @@ import { found } from './read.mjs';
|
|
|
12
12
|
export const HFS_COVERAGE_BELOW = 'HFS_COVERAGE_BELOW';
|
|
13
13
|
const METRICS = ['lines', 'functions', 'branches'];
|
|
14
14
|
const SUMMARY = { LF: ['lines', 'found'], LH: ['lines', 'hit'], FNF: ['functions', 'found'], FNH: ['functions', 'hit'], BRF: ['branches', 'found'], BRH: ['branches', 'hit'] };
|
|
15
|
-
const SOURCE = /\.ts$/;
|
|
16
15
|
const SPEC = /(?:\.(?:spec|test)|-spec)\.ts$/;
|
|
17
16
|
const countOf = (value) => /^\d+$/.test(value) && Number.isSafeInteger(Number(value)) ? Number(value) : null;
|
|
18
17
|
const slash = (value) => path.posix.normalize(String(value).replaceAll('\\', '/'));
|
|
@@ -66,7 +65,10 @@ function parseLcov(text) {
|
|
|
66
65
|
const records = [];
|
|
67
66
|
const problems = [];
|
|
68
67
|
let current = null;
|
|
69
|
-
const flush = () => {
|
|
68
|
+
const flush = () => {
|
|
69
|
+
if (current) records.push(current);
|
|
70
|
+
current = null;
|
|
71
|
+
};
|
|
70
72
|
for (const [index, raw] of String(text).replace(/^\uFEFF/, '').split(/\r?\n/).entries()) {
|
|
71
73
|
const line = raw.trim();
|
|
72
74
|
if (!line) continue;
|
|
@@ -102,7 +104,7 @@ function inventory({ files, all, repo, manifest }) {
|
|
|
102
104
|
const scope = coverageScope(effective, repo.providers ?? repo.sides?.be?.providers ?? []);
|
|
103
105
|
const roots = scope.roots.map((dir) => globExpression(`${dir}**`));
|
|
104
106
|
const excludes = scope.excludes.map((dir) => globExpression(`${dir}**`));
|
|
105
|
-
const candidates = [...new Set((all ?? files ?? []).map(slash))].filter((file) => file.startsWith(prefix) &&
|
|
107
|
+
const candidates = [...new Set((all ?? files ?? []).map(slash))].filter((file) => file.startsWith(prefix) && file.endsWith('.ts') && !SPEC.test(file)).sort(byCodeUnit);
|
|
106
108
|
const measured = [];
|
|
107
109
|
const unmeasured = [];
|
|
108
110
|
for (const file of candidates) {
|
|
@@ -74,7 +74,7 @@ async function principalHelpers(analyses) {
|
|
|
74
74
|
try { bodies.push({ name: fn.name, ast: await parseSql(body) }); } catch { /* PL/pgSQL safety is checked separately. */ }
|
|
75
75
|
});
|
|
76
76
|
const helpers = new Set();
|
|
77
|
-
for (let
|
|
77
|
+
for (let remaining = bodies.length; remaining > 0; remaining -= 1) {
|
|
78
78
|
for (const body of bodies) if (hasPrincipal(body.ast, helpers)) helpers.add(body.name);
|
|
79
79
|
}
|
|
80
80
|
return helpers;
|
|
@@ -13,7 +13,10 @@ import { fileURLToPath } from 'node:url';
|
|
|
13
13
|
import { loadTypescript } from '../../lib/package-at.mjs';
|
|
14
14
|
|
|
15
15
|
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
16
|
-
const
|
|
16
|
+
const MIGRATION_PLAIN = /[a-z0-9]+(?:-[a-z0-9]+)*/;
|
|
17
|
+
const MIGRATION_CONTRACT_TAIL = /contract_[a-z0-9]+(?:[-_][a-z0-9]+)*/;
|
|
18
|
+
const MIGRATION_SLUG = [MIGRATION_PLAIN.source, MIGRATION_CONTRACT_TAIL.source, `${MIGRATION_PLAIN.source}_${MIGRATION_CONTRACT_TAIL.source}`].join('|');
|
|
19
|
+
const MIGRATION_NAME = new RegExp(String.raw`^(\d{14})_(${MIGRATION_SLUG})\.sql$`);
|
|
17
20
|
export const isMigrationName = (name) => MIGRATION_NAME.test(name);
|
|
18
21
|
const RESTAMP_HEADER = /^-- starci:restamp (\d{14}) (\d{14})(?:\r?\n|$)/;
|
|
19
22
|
const withoutRestampHeader = (text) => text?.replace(RESTAMP_HEADER, '') ?? null;
|
|
@@ -227,7 +230,11 @@ export function databaseSqlTokens(text) {
|
|
|
227
230
|
let index = 0;
|
|
228
231
|
let line = 1;
|
|
229
232
|
while (index < text.length) {
|
|
230
|
-
if (/\s/.test(text[index])) {
|
|
233
|
+
if (/\s/.test(text[index])) {
|
|
234
|
+
if (text[index] === '\n') line += 1;
|
|
235
|
+
index += 1;
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
231
238
|
const end = commentEnd(text, index);
|
|
232
239
|
if (end !== null) { line += text.slice(index, end).split('\n').length - 1; index = end; continue; }
|
|
233
240
|
const token = sqlTokenAt(text, index);
|
|
@@ -243,7 +250,10 @@ export function databaseSqlStatements(tokens) {
|
|
|
243
250
|
const statements = [];
|
|
244
251
|
let current = [];
|
|
245
252
|
for (const token of tokens) {
|
|
246
|
-
if (token.t === 'punct' && token.v === ';') {
|
|
253
|
+
if (token.t === 'punct' && token.v === ';') {
|
|
254
|
+
if (current.length) statements.push(current);
|
|
255
|
+
current = [];
|
|
256
|
+
}
|
|
247
257
|
else current.push(token);
|
|
248
258
|
}
|
|
249
259
|
if (current.length) statements.push(current);
|
|
@@ -290,7 +290,7 @@ async function functionFindings(file, fn, facts, exposed) {
|
|
|
290
290
|
|
|
291
291
|
function alteredDefinerFindings(file, alter, facts, exposed) {
|
|
292
292
|
if (!alter.securityDefiner) return [];
|
|
293
|
-
const created = facts.functions.
|
|
293
|
+
const created = facts.functions.findLast((fn) => fn.index < alter.index && matchesAlter(fn, alter));
|
|
294
294
|
const node = created?.node ?? { func: alter.node.func };
|
|
295
295
|
const fn = { ...alter, node, altered: true, originIndex: created?.index ?? -1 };
|
|
296
296
|
return definerDeclarationFindings(file, fn, facts, exposed, true);
|
|
@@ -104,7 +104,7 @@ function inspectDo(state, node, file) {
|
|
|
104
104
|
const option = (key) => (node.args ?? []).find((item) => item.DefElem?.defname === key)?.DefElem?.arg?.String?.sval;
|
|
105
105
|
const tree = plpgsqlTree(option('as') ?? '');
|
|
106
106
|
walk(tree, (item) => {
|
|
107
|
-
if (item.PLpgSQL_expr
|
|
107
|
+
if (item.PLpgSQL_expr?.parseMode === 0) inspectCalls(state, sqlTree(item.PLpgSQL_expr.query), file, true);
|
|
108
108
|
if (item.PLpgSQL_stmt_dynexecute) {
|
|
109
109
|
const expression = sqlTree(`select ${item.PLpgSQL_stmt_dynexecute.query?.PLpgSQL_expr?.query ?? ''}`);
|
|
110
110
|
dynamicOwnership(state, expression, file, true);
|
|
@@ -128,7 +128,7 @@ function grantCheck(state, node, file) {
|
|
|
128
128
|
function recordGrant(state, node, file, executed) {
|
|
129
129
|
state.grants.push({ node, file, created: new Map(state.created), executed });
|
|
130
130
|
const grant = node.GrantStmt;
|
|
131
|
-
if (!executed ||
|
|
131
|
+
if (!executed || grant?.objtype !== 'OBJECT_TABLE' || grant.targtype !== 'ACL_TARGET_OBJECT') return;
|
|
132
132
|
if (!(grant.grantees ?? []).some((item) => item.RoleSpec?.rolename === state.appRole)) return;
|
|
133
133
|
const privileges = grant.privileges?.map((item) => item.AccessPriv?.priv_name).filter(Boolean) ?? TABLE_PRIVILEGES;
|
|
134
134
|
for (const object of grant.objects ?? []) {
|
|
@@ -8,17 +8,61 @@ const DEPLOY = /(?:^|\/)deploy\//;
|
|
|
8
8
|
const SCRIPT = /(?:^|\/)scripts\//;
|
|
9
9
|
const COMPOSE = /(?:^|\/)(?:docker-)?compose(?:[.-][^/]*)?\.ya?ml$/;
|
|
10
10
|
const DOCKERFILE = /(?:^|\/)Dockerfile(?:\.[^/]*)?$/;
|
|
11
|
-
const
|
|
12
|
-
const
|
|
11
|
+
const CREDENTIAL_FILE_PARTS = [/(?:^|\/)secrets?\.env(?:\.[^/]*)?/, /(?:^|\/)\.env(?:\.[^/]*)?/, /(?:^|\/)secrets\//, /\.enc$/];
|
|
12
|
+
const CREDENTIAL_FILE = new RegExp(CREDENTIAL_FILE_PARTS.map((part) => part.source).join('|'), 'i');
|
|
13
|
+
const AT_JOB_ARGUMENT = /-[ft]\b|now\b|tomorrow\b|\d{1,2}:\d{2}\b|midnight\b|noon\b/.source;
|
|
14
|
+
const HOST_CRON_PARTS = [
|
|
15
|
+
/\b(?:crontab|anacron|crond|supercronic)\b/,
|
|
16
|
+
/\/etc\/cron(?:\b|[./])/,
|
|
17
|
+
/\bcron\.d\b/,
|
|
18
|
+
/\/var\/spool\/cron\b/,
|
|
19
|
+
/@(?:reboot|yearly|annually|monthly|weekly|daily|midnight|hourly)\b/,
|
|
20
|
+
/\b[\w.@-]+\.timer\b/,
|
|
21
|
+
/\bOn(?:Calendar|BootSec|UnitActiveSec|ActiveSec)\s*=/,
|
|
22
|
+
new RegExp(String.raw`(?:^|[\s;|&"'])at\s+(?:-[a-z]+\s+)*(?:${AT_JOB_ARGUMENT})`),
|
|
23
|
+
];
|
|
24
|
+
const HOST_CRON = new RegExp(HOST_CRON_PARTS.map((part) => part.source).join('|'), 'im');
|
|
13
25
|
const HOST_CRON_PATH = /(?:^|\/)(?:crontab(?:$|\/)|cron\.d(?:\/|$)|[^/]+\.(?:timer|crontab)$)/;
|
|
14
26
|
const CRON_NUMERIC_FIELD = /^[\d*,/-]+$/;
|
|
15
27
|
const CRON_NAMED_FIELD = /^[\w*,/-]+$/;
|
|
16
|
-
const
|
|
17
|
-
|
|
28
|
+
const CRON_CONTAINER_PARTS = [
|
|
29
|
+
/\bimage\s*:\s*["']?(?:[^\s"']*\/)?(?:cron|crond|supercronic|ofelia)(?=[:@\s"']|$)/,
|
|
30
|
+
/\b(?:command|entrypoint)\s*:[^\n]*\b(?:cron|crond|supercronic)\b/,
|
|
31
|
+
];
|
|
32
|
+
const CRON_CONTAINER = new RegExp(CRON_CONTAINER_PARTS.map((part) => part.source).join('|'), 'i');
|
|
33
|
+
const SLEEP_LOOP_HEAD_PARTS = [/while\s+(?:true|:|\[\s*1\s*\])/, /for\s*\(\(\s*;\s*;\s*\)\)/];
|
|
34
|
+
const SLEEP_LOOP_HEAD = new RegExp(String.raw`\b(?:${SLEEP_LOOP_HEAD_PARTS.map((part) => part.source).join('|')})`, 'gi');
|
|
35
|
+
const SLEEP_LOOP_BREAK = /[^\S\n]*[;\n]/y;
|
|
36
|
+
const SLEEP_LOOP_SLEEP = /\bsleep\s+/gi;
|
|
37
|
+
const SLEEP_LOOP_REACH = 4000;
|
|
38
|
+
/** An endless-loop head, a statement break, then a `sleep` within SLEEP_LOOP_REACH characters: searched in steps so no regex backtracks across the pair. */
|
|
39
|
+
const SLEEP_LOOP = {
|
|
40
|
+
test(text) {
|
|
41
|
+
for (const head of text.matchAll(SLEEP_LOOP_HEAD)) {
|
|
42
|
+
SLEEP_LOOP_BREAK.lastIndex = head.index + head[0].length;
|
|
43
|
+
if (!SLEEP_LOOP_BREAK.test(text)) continue;
|
|
44
|
+
SLEEP_LOOP_SLEEP.lastIndex = SLEEP_LOOP_BREAK.lastIndex;
|
|
45
|
+
const sleep = SLEEP_LOOP_SLEEP.exec(text);
|
|
46
|
+
if (sleep && sleep.index - SLEEP_LOOP_BREAK.lastIndex <= SLEEP_LOOP_REACH) return true;
|
|
47
|
+
}
|
|
48
|
+
return false;
|
|
49
|
+
},
|
|
50
|
+
};
|
|
18
51
|
const INLINE_NODE = /\bnode\b[^\n;&|]*?\s(?:-(?:e|p|pe)\b|--(?:eval|print)(?:\s|=))/;
|
|
19
|
-
const
|
|
52
|
+
const INLINE_SQL_ACTION_PARTS = [
|
|
53
|
+
/insert\s+into/,
|
|
54
|
+
/update\s+[\w."']+\s+set/,
|
|
55
|
+
/delete\s+from/,
|
|
56
|
+
/create\s+(?:table|function|view)/,
|
|
57
|
+
/alter\s+table/,
|
|
58
|
+
/truncate\s+(?:table\s+)?[\w."']/,
|
|
59
|
+
];
|
|
60
|
+
const INLINE_SQL_ACTION = new RegExp(String.raw`\b(?:${INLINE_SQL_ACTION_PARTS.map((part) => part.source).join('|')})\b`, 'i');
|
|
20
61
|
const REMOTE_SHELL = /\b(?:curl|wget)\b[^|\n]*\|\s*(?:sudo\s+)?(?:ba|da|z)?sh\b/i;
|
|
21
|
-
const
|
|
62
|
+
const DOCKER_GLOBAL_OPTION = /(?:--(?:context|host)(?:\s+|=)|-H\s+)\S+/.source;
|
|
63
|
+
const DOCKER_COMPOSE_COMMAND = new RegExp(String.raw`\bdocker(?:\s+${DOCKER_GLOBAL_OPTION})*\s+compose\b[^\n;&|]*`).source;
|
|
64
|
+
const DOCKER_COMPOSE_V1_COMMAND = /\bdocker-compose\b[^\n;&|]*/.source;
|
|
65
|
+
const COMPOSE_COMMAND = new RegExp(`${DOCKER_COMPOSE_COMMAND}|${DOCKER_COMPOSE_V1_COMMAND}`, 'g');
|
|
22
66
|
const COMPOSE_OPTIONS = new Set(['-f', '--file', '-p', '--project-name', '--project-directory', '--env-file', '--profile', '--parallel', '--ansi', '--progress']);
|
|
23
67
|
const DEV = /(?:^|[\s/._="'-])(?:dev|development|local)(?=$|[\s/._"'-])/i;
|
|
24
68
|
const NON_DEV = /(?:^|[\s/._="'-])(?:prod|production|staging|stage|vps|preview)(?=$|[\s/._"'-])/i;
|
|
@@ -59,7 +59,17 @@ const validPermissions = (value) => record(value) || value === 'read-all' || val
|
|
|
59
59
|
const validConcurrency = (value) => nonempty(value) || (record(value) && nonempty(value.group));
|
|
60
60
|
const deployName = (value) => !/authorize[\s_.-]+deploy[\s_.-]+key/i.test(value) && /(?:^|[\s/_.-])deploy(?:[\s/_.-]|$)/i.test(value);
|
|
61
61
|
const materializeCommand = (script) => commands(script).some(({ text }) => /^starci\s+app\s+secret\s+materialize\b/.test(commandBody(text)));
|
|
62
|
-
const
|
|
62
|
+
const DEPLOYMENT_COMMANDS = [
|
|
63
|
+
/^starci\s+(?:app\s+)?(?:deploy|secret\s+materialize)\b/,
|
|
64
|
+
/^docker\s+stack\s+deploy\b/,
|
|
65
|
+
/^(?:ba?sh\s+)?(?:\.\/)?deploy\//,
|
|
66
|
+
/^(?:ba?sh\s+)?(?:\.\/)?(?:scripts\/)?[\w.-]*deploy[\w.-]*\.sh\b/,
|
|
67
|
+
/^npm\s+run\s+deploy(?=[:\s]|$)/,
|
|
68
|
+
];
|
|
69
|
+
const deploymentCommand = (script) => commands(script).some(({ text }) => {
|
|
70
|
+
const body = commandBody(text);
|
|
71
|
+
return DEPLOYMENT_COMMANDS.some((pattern) => pattern.test(body));
|
|
72
|
+
});
|
|
63
73
|
|
|
64
74
|
function hasAgeIdentity(job, doc) {
|
|
65
75
|
const steps = activeSteps(job);
|
|
@@ -52,10 +52,15 @@ function resolvedPath(raw, file, tracked, link, generated, roots) {
|
|
|
52
52
|
return candidates.some((candidate) => !candidate.startsWith('../') && (trackedPath(candidate, tracked) || generated.has(candidate)));
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
+
const ENV_NAME = /[A-Z][A-Z0-9_]*/.source;
|
|
56
|
+
const INLINE_LINK = /\]\(\s*(?:<([^>]+)>|([^\s)]+))(?:\s+['"][^)]*)?\)/.source;
|
|
57
|
+
const REFERENCE_LINK = /^\s*\[[^\]]+\]:\s*(?:<([^>]+)>|(\S+))/.source;
|
|
58
|
+
const MARKDOWN_LINK = new RegExp(`${INLINE_LINK}|${REFERENCE_LINK}`, 'g');
|
|
59
|
+
|
|
55
60
|
function pathReferences(line) {
|
|
56
61
|
const refs = [];
|
|
57
62
|
// Markdown inline and reference links, including image links. URLs and anchors are excluded later.
|
|
58
|
-
for (const match of line.matchAll(
|
|
63
|
+
for (const match of line.matchAll(MARKDOWN_LINK)) {
|
|
59
64
|
refs.push({ target: match[1] ?? match[2] ?? match[3] ?? match[4], link: true });
|
|
60
65
|
}
|
|
61
66
|
const masked = line.replace(/\]\([^)]*\)/g, ']');
|
|
@@ -171,9 +176,14 @@ function importedFiles(file, text, tracked) {
|
|
|
171
176
|
});
|
|
172
177
|
}
|
|
173
178
|
|
|
179
|
+
const READER_CALL = /\b(\w+)\s*\.\s*\w+\s*\(\s*['"]([A-Z][A-Z0-9_]*)['"]/.source;
|
|
180
|
+
const PROCESS_ENV_DOT = /\bprocess\.env\.([A-Z][A-Z0-9_]*)/.source;
|
|
181
|
+
const PROCESS_ENV_INDEX = /\bprocess\.env\[['"]([A-Z][A-Z0-9_]*)['"]\]/.source;
|
|
182
|
+
const ENV_READ = new RegExp(`${READER_CALL}|${PROCESS_ENV_DOT}|${PROCESS_ENV_INDEX}`, 'g');
|
|
183
|
+
|
|
174
184
|
function addReaderKeys(text, keys) {
|
|
175
185
|
const readers = new Set(['env', 'source', ...[...text.matchAll(/\b(\w+)\s*:\s*EnvSource\b/g)].map((match) => match[1])]);
|
|
176
|
-
for (const match of text.matchAll(
|
|
186
|
+
for (const match of text.matchAll(ENV_READ)) {
|
|
177
187
|
if (match[3] || match[4]) keys.add(match[3] ?? match[4]);
|
|
178
188
|
else if (readers.has(match[1])) keys.add(match[2]);
|
|
179
189
|
}
|
|
@@ -242,6 +252,11 @@ function unknownEnvironmentName(name, line, keys, codes, tokens, explicit) {
|
|
|
242
252
|
return !keys.has(name) && !(name.endsWith('_FILE') && keys.has(name.slice(0, -5)));
|
|
243
253
|
}
|
|
244
254
|
|
|
255
|
+
const QUOTED_ENV_NAME = `\`(${ENV_NAME})\``;
|
|
256
|
+
const REFERENCED_ENV_NAME = String.raw`\b(?:process\.env\.|\$\{?)(${ENV_NAME})`;
|
|
257
|
+
const ASSIGNED_ENV_NAME = String.raw`\b(${ENV_NAME})\s*=`;
|
|
258
|
+
const ENV_NAME_CITATION = new RegExp(`${QUOTED_ENV_NAME}|${REFERENCED_ENV_NAME}|${ASSIGNED_ENV_NAME}`, 'g');
|
|
259
|
+
|
|
245
260
|
function nameFindings(file, lines, keys, rules, tokens) {
|
|
246
261
|
const findings = [];
|
|
247
262
|
const codes = new Set(rules.rules.flatMap((rule) => rule.failureCodes));
|
|
@@ -249,7 +264,7 @@ function nameFindings(file, lines, keys, rules, tokens) {
|
|
|
249
264
|
for (const match of line.matchAll(/\bR\d+\b/g)) {
|
|
250
265
|
if (!rules.rule(match[0])) findings.push(found(RULE_UNKNOWN, file, `${file} cites unknown rule ${match[0]}; use an id from the HFS rule catalog`, { line: index + 1, reference: match[0] }));
|
|
251
266
|
}
|
|
252
|
-
for (const match of line.matchAll(
|
|
267
|
+
for (const match of line.matchAll(ENV_NAME_CITATION)) {
|
|
253
268
|
const name = match[1] ?? match[2] ?? match[3];
|
|
254
269
|
if (!unknownEnvironmentName(name, line, keys, codes, tokens, !match[1])) continue;
|
|
255
270
|
findings.push(found(ENV_UNKNOWN, file, `${file} cites unknown environment key ${name}; declare it in a typed environment reader or KEYS.md, or update the documentation`, { line: index + 1, reference: name }));
|
|
@@ -222,7 +222,10 @@ function masksProbeFailure(body) {
|
|
|
222
222
|
for (let index = 0; index < body.length; index += 1) {
|
|
223
223
|
const char = body[index];
|
|
224
224
|
if (char === '\\') { index += 1; continue; }
|
|
225
|
-
if (quote) {
|
|
225
|
+
if (quote) {
|
|
226
|
+
if (char === quote) quote = null;
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
226
229
|
if (char === '"' || char === "'") { quote = char; continue; }
|
|
227
230
|
if (char === ';' || char === '|') return true;
|
|
228
231
|
}
|
|
@@ -35,12 +35,10 @@ const APP_MAIN = /(?:^|\/)apps\/[^/]+\/src\/main\.ts$/u;
|
|
|
35
35
|
const SEALED_MEMBER = /^\.starcistacks\/[^/]+\/secrets\/[^/]+\.enc$/u;
|
|
36
36
|
const KEYS_ROSTER = /^\.starcistacks\/[^/]+\/runtime\/env\/KEYS\.md$/u;
|
|
37
37
|
const STACK_YAML = /^\.starcistacks\/.+\.ya?ml$/u;
|
|
38
|
-
const ENV_EXAMPLE = /\.env\.example$/u;
|
|
39
|
-
const PUBLIC_PREFIX = /^NEXT_PUBLIC_/u;
|
|
40
38
|
const KEY_NAME = /^[A-Za-z_]\w*$/u;
|
|
41
39
|
const FILE_SUFFIX = /_FILE$/u;
|
|
42
40
|
const EXAMPLE_LINE = /^[ \t]*(?:export[ \t]+)?([A-Za-z_]\w*)[ \t]*=(.*)$/u;
|
|
43
|
-
const ROSTER_KEY = /`([A-Za-z_]
|
|
41
|
+
const ROSTER_KEY = /`([A-Za-z_]\w*)`/gu;
|
|
44
42
|
|
|
45
43
|
const REQUIRED_ALWAYS = new Set(['string', 'url', 'secret', 'enum']);
|
|
46
44
|
const FALLBACK_OPTIONAL = new Set(['int', 'bool', 'duration']);
|
|
@@ -274,14 +272,17 @@ function declaredYamlEntry(key, value, add) {
|
|
|
274
272
|
/** The env names one yaml node declares: `environment:`/`env:` keys or `K=V` entries, `runtimeSecrets`, `secrets` and `vars[].name`. */
|
|
275
273
|
function declaredFromYaml(node, add) {
|
|
276
274
|
if (!node || typeof node !== 'object') return;
|
|
277
|
-
if (Array.isArray(node)) {
|
|
275
|
+
if (Array.isArray(node)) {
|
|
276
|
+
for (const item of node) declaredFromYaml(item, add);
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
278
279
|
for (const [key, value] of Object.entries(node)) declaredYamlEntry(key, value, add);
|
|
279
280
|
}
|
|
280
281
|
|
|
281
282
|
function addDeclaredKey(declared, name) {
|
|
282
283
|
if (!KEY_NAME.test(name)) return;
|
|
283
284
|
declared.add(name);
|
|
284
|
-
if (
|
|
285
|
+
if (name.endsWith('_FILE')) declared.add(name.replace(FILE_SUFFIX, ''));
|
|
285
286
|
}
|
|
286
287
|
|
|
287
288
|
function declareKeysFromText(file, text, add) {
|
|
@@ -314,7 +315,7 @@ function addRequiredFindings(reads, sealed, declared, findings) {
|
|
|
314
315
|
|
|
315
316
|
function sealedMemberFinding(key, member, reads, declared) {
|
|
316
317
|
// The specific public-custody code wins over the generic unread one for a NEXT_PUBLIC_* key.
|
|
317
|
-
if (
|
|
318
|
+
if (key.startsWith('NEXT_PUBLIC_')) {
|
|
318
319
|
return found(ENV_PUBLIC_SEALED, member, `${member} seals the public key ${key}; NEXT_PUBLIC_* values are public configuration and never enter custody`);
|
|
319
320
|
}
|
|
320
321
|
if (!reads.has(key) && !declared.has(key)) {
|
|
@@ -347,7 +348,7 @@ function addDuplicateFindings(key, members, findings) {
|
|
|
347
348
|
}
|
|
348
349
|
|
|
349
350
|
function addExampleFindings(repoRoot, all, reads, findings) {
|
|
350
|
-
for (const file of all.filter((rel) =>
|
|
351
|
+
for (const file of all.filter((rel) => rel.endsWith('.env.example'))) {
|
|
351
352
|
const text = readText(repoRoot, file);
|
|
352
353
|
if (text === null) continue;
|
|
353
354
|
for (const { key } of envExampleEntries(text)) {
|
|
@@ -19,8 +19,6 @@ import { found, readText } from './read.mjs';
|
|
|
19
19
|
export const ENV_VALUE_SHAPE = 'ENV_VALUE_SHAPE';
|
|
20
20
|
|
|
21
21
|
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
22
|
-
const FILE_SUFFIX = /_FILE$/u;
|
|
23
|
-
const ENV_EXAMPLE = /\.env\.example$/u;
|
|
24
22
|
const SKIP_DIRECTORY = new Set(['node_modules', 'dist', 'coverage', '.git']);
|
|
25
23
|
|
|
26
24
|
const SHAPES = Object.freeze({
|
|
@@ -61,7 +59,7 @@ function listEnvSourceFiles(repoRoot) {
|
|
|
61
59
|
function shapeViolations(reads, map) {
|
|
62
60
|
const violations = [];
|
|
63
61
|
for (const [key, value] of Object.entries(map)) {
|
|
64
|
-
if (
|
|
62
|
+
if (key.endsWith('_FILE')) continue;
|
|
65
63
|
const shape = reads.get(key)?.shape;
|
|
66
64
|
if (shape && !shapeOk(shape, value)) violations.push({ key, expected: shapeText(shape) });
|
|
67
65
|
}
|
|
@@ -71,7 +69,7 @@ function shapeViolations(reads, map) {
|
|
|
71
69
|
/** The findings of R249 over the `*.env.example` files of an app judged at its root scope (both editions). */
|
|
72
70
|
export function envValueShapeFindings({ repoRoot, all = [], isRoot }) {
|
|
73
71
|
if (!isRoot) return [];
|
|
74
|
-
const examples = all.filter((file) =>
|
|
72
|
+
const examples = all.filter((file) => file.endsWith('.env.example'));
|
|
75
73
|
if (!examples.length) return [];
|
|
76
74
|
const reads = collectEnvReads(repoRoot, all);
|
|
77
75
|
if (reads === null) return [];
|
|
@@ -91,7 +91,7 @@ function requiredNames(root, files, app, ts) {
|
|
|
91
91
|
const names = new Set();
|
|
92
92
|
for (const file of files.filter(file => file.startsWith(prefix))) {
|
|
93
93
|
const local = file.slice(prefix.length);
|
|
94
|
-
const source = SOURCE.test(local) && (local.startsWith('src/') ||
|
|
94
|
+
const source = SOURCE.test(local) && (local.startsWith('src/') || local.startsWith('next.config.') || SCHEMA.test(local));
|
|
95
95
|
const schema = SCHEMA.test(local) && /\.(?:json|ya?ml)$/u.test(local);
|
|
96
96
|
if (!source && !schema) continue;
|
|
97
97
|
const text = readText(root, file);
|
|
@@ -13,7 +13,10 @@ import { found, readJson, readText } from './read.mjs';
|
|
|
13
13
|
|
|
14
14
|
export const HFS_RELEASE_TAG_MISMATCH = 'HFS_RELEASE_TAG_MISMATCH';
|
|
15
15
|
export const HFS_DB_DEPLOY_UNGUARDED = 'HFS_DB_DEPLOY_UNGUARDED';
|
|
16
|
-
const
|
|
16
|
+
const SEMVER_NUMBER = String.raw`(?:0|[1-9]\d*)`;
|
|
17
|
+
const SEMVER_PRERELEASE_ID = String.raw`(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)`;
|
|
18
|
+
const SEMVER_BUILD_ID = '[0-9A-Za-z-]+';
|
|
19
|
+
const SEMVER = new RegExp(String.raw`^${SEMVER_NUMBER}\.${SEMVER_NUMBER}\.${SEMVER_NUMBER}(?:-${SEMVER_PRERELEASE_ID}(?:\.${SEMVER_PRERELEASE_ID})*)?(?:\+${SEMVER_BUILD_ID}(?:\.${SEMVER_BUILD_ID})*)?$`);
|
|
17
20
|
const WORKFLOW = /^\.github\/workflows\/(images|db-deploy)\.ya?ml$/;
|
|
18
21
|
const TAG = 1;
|
|
19
22
|
const VERSION_PREFIX = 2;
|
|
@@ -42,7 +45,7 @@ function splitBoolean(expression, operator) {
|
|
|
42
45
|
let start = 0, depth = 0, quote = '';
|
|
43
46
|
for (let i = 0; i < expression.length; i += 1) {
|
|
44
47
|
const char = expression[i];
|
|
45
|
-
if (quote) { if (char === quote) quote = ''; continue; }
|
|
48
|
+
if (quote) { if (char === quote) { quote = ''; } continue; }
|
|
46
49
|
if (char === "'" || char === '"') { quote = char; continue; }
|
|
47
50
|
if (char === '(') depth += 1;
|
|
48
51
|
if (char === ')') depth -= 1;
|
|
@@ -56,7 +59,7 @@ function enclosingParentheses(text) {
|
|
|
56
59
|
let depth = 0, quote = '';
|
|
57
60
|
for (let i = 0; i < text.length - 1; i += 1) {
|
|
58
61
|
const char = text[i];
|
|
59
|
-
if (quote) { if (char === quote) quote = ''; continue; }
|
|
62
|
+
if (quote) { if (char === quote) { quote = ''; } continue; }
|
|
60
63
|
if (char === "'" || char === '"') { quote = char; continue; }
|
|
61
64
|
if (char === '(') depth += 1;
|
|
62
65
|
if (char === ')' && --depth === 0) return false;
|
|
@@ -109,7 +112,7 @@ function shellTagGuard(step) {
|
|
|
109
112
|
}
|
|
110
113
|
|
|
111
114
|
function checkoutPreservesRef(job) {
|
|
112
|
-
return (job.steps ?? []).filter((step) =>
|
|
115
|
+
return (job.steps ?? []).filter((step) => (step.uses ?? '').startsWith('actions/checkout@')).every((step) => {
|
|
113
116
|
const ref = step.with?.ref;
|
|
114
117
|
return ref === undefined || /^\$\{\{\s*github\.(?:ref|ref_name|sha)\s*\}\}$/.test(String(ref));
|
|
115
118
|
});
|
|
@@ -174,7 +177,7 @@ function imageFindings(file, workflow, version) {
|
|
|
174
177
|
let builds = 0;
|
|
175
178
|
for (const [name, job] of Object.entries(workflow.jobs ?? {})) {
|
|
176
179
|
for (const step of job?.steps ?? []) {
|
|
177
|
-
if (
|
|
180
|
+
if (!(step.uses ?? '').startsWith('docker/build-push-action@')) continue;
|
|
178
181
|
builds += 1;
|
|
179
182
|
const guarded = tagOnlyTrigger(workflow) || refProof(job.if) === RELEASE_REF || refProof(step.if) === RELEASE_REF;
|
|
180
183
|
if (!guarded || !checkoutPreservesRef(job) || !imageTagIsRelease(step.with?.tags, version)) findings.push(found(HFS_RELEASE_TAG_MISMATCH, file, `${file} job ${name} must build the checked-out release ref and tag every image with v<package.json version> or github.ref_name under a release-tag guard`, { job: name }));
|