@starci/hfs 4.7.12 → 4.7.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +11 -0
- package/README.md +1 -1
- package/package.json +1 -1
- package/release/gate.mjs +388 -378
- package/release/lint-report.mjs +52 -0
- package/runtime/knowledge/hfs/canon-pins.yaml +4 -4
- package/runtime/knowledge/hfs/ci-standard.yaml +1 -1
- package/runtime/knowledge/hfs/rules.yaml +7 -6
- package/runtime/knowledge/patterns/be/cli.yaml +1 -0
- package/runtime/knowledge/patterns/be/realtime.yaml +1 -0
- package/runtime/knowledge/patterns/be/webhooks.yaml +1 -0
- package/runtime/modules/kernel/failure-codes.yaml +24 -2
- package/runtime/packages/cli/src/catalog.generated.mjs +12 -6
- package/runtime/scripts/hfs/architecture/canon-clones.mjs +1 -1
- package/runtime/scripts/hfs/architecture/hfs.mjs +19 -4
- package/runtime/scripts/hfs/architecture/i18n-keys.mjs +6 -4
- package/runtime/scripts/hfs/architecture/transport-owner.mjs +1 -1
- package/runtime/scripts/hfs/ci-declaration.mjs +5 -2
- package/runtime/scripts/hfs/db-baseline-verb.mjs +5 -2
- package/runtime/scripts/hfs/doc-commands.mjs +2 -1
- package/runtime/scripts/hfs/rule-catalog.mjs +5 -2
- package/runtime/scripts/hfs/rules/coverage-below.mjs +5 -3
- package/runtime/scripts/hfs/rules/database-access.mjs +1 -1
- package/runtime/scripts/hfs/rules/database-migrations.mjs +13 -3
- package/runtime/scripts/hfs/rules/database-plpgsql.mjs +1 -1
- package/runtime/scripts/hfs/rules/database-queues.mjs +2 -2
- package/runtime/scripts/hfs/rules/deploy-scan.mjs +50 -6
- package/runtime/scripts/hfs/rules/deploy-workflow.mjs +11 -1
- package/runtime/scripts/hfs/rules/doc-fresh.mjs +18 -3
- package/runtime/scripts/hfs/rules/docker-health.mjs +4 -1
- package/runtime/scripts/hfs/rules/env-parity.mjs +8 -7
- package/runtime/scripts/hfs/rules/env-value-shape.mjs +2 -4
- package/runtime/scripts/hfs/rules/fe-build-args.mjs +1 -1
- package/runtime/scripts/hfs/rules/release-provenance.mjs +8 -5
- package/runtime/scripts/hfs/rules/script-hygiene.mjs +23 -7
- package/runtime/scripts/hfs/rules/services.mjs +19 -5
- package/runtime/scripts/hfs/rules/stack-tooling.mjs +5 -2
- package/runtime/scripts/hfs/rules/stacks-swarm.mjs +5 -5
- package/runtime/scripts/hfs/rules/staged-specs.mjs +26 -0
- package/runtime/scripts/hfs/rules/supply-chain.mjs +3 -3
- package/runtime/scripts/hfs/rules/unused-symbol-properties.mjs +1 -1
- package/runtime/scripts/hfs/rules/unused-symbol.mjs +8 -4
- package/runtime/scripts/hfs/sealed-members.mjs +1 -1
- package/runtime/scripts/hfs/secret-bootstrap-crypto.mjs +1 -1
- package/runtime/scripts/hfs/secret-files.mjs +1 -1
- package/runtime/scripts/hfs/secret-sops.mjs +1 -1
- package/runtime/scripts/hfs/stack-deploy-config.mjs +19 -2
- package/runtime/scripts/lib/env.mjs +2 -1
- package/runtime/scripts/lib/git-hooks-dir.mjs +30 -0
- package/runtime/scripts/lib/path-key.mjs +1 -1
- package/runtime/scripts/lib/roots.mjs +3 -3
- package/runtime/scripts/lib/secret-patterns.mjs +1 -1
- package/runtime/scripts/lib/swarm-stack.mjs +4 -2
- package/src/main.mjs +2 -2
- package/sync/ci-commands.mjs +275 -275
- package/sync/hygiene.mjs +19 -4
- package/templates/app/ci-workflows-lite/github/workflows/ci.yml +2 -0
|
@@ -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 }));
|
|
@@ -7,16 +7,32 @@ const PLAINTEXT_LOAD = 'HFS_SCRIPT_PLAINTEXT_LOAD';
|
|
|
7
7
|
const XTRACE = 'HFS_SCRIPT_XTRACE';
|
|
8
8
|
const SERVICE_ROLE = 'HFS_SERVICE_ROLE_SCOPE';
|
|
9
9
|
const SUPABASE_OWNER = 'be.integrations.supabase';
|
|
10
|
-
const
|
|
10
|
+
const SECRET_ENV_FILE = /(?:^|\/)(?:secrets?\.env|\.env)(?:\.[^/]*)?/iu;
|
|
11
|
+
const SECRET_NIVO_FILE = /(?:^|\/)\.nivo-lite(?:\/|$)/iu;
|
|
12
|
+
const SECRET_ENC_FILE = /\.enc$/iu;
|
|
13
|
+
const isSecretFile = (file) => SECRET_ENV_FILE.test(file) || SECRET_NIVO_FILE.test(file) || SECRET_ENC_FILE.test(file);
|
|
11
14
|
const EXCLUDED_TREE = /(?:^|\/)(?:node_modules|\.git|\.claude|\.secrets|secrets|tests?|__tests__|docs|coverage|dist|build)(?:\/|$)/u;
|
|
12
15
|
const TEST_FILE = /(?:\.(?:spec|test)|-spec)\.[cm]?[jt]sx?$/u;
|
|
13
16
|
const SOURCE = /\.(?:[cm]?[jt]sx?|sh|bash|zsh|ksh|ps1|psm1|bat|cmd|py|rb|pl)$/iu;
|
|
14
17
|
const WORKFLOW = /(?:^|\/)\.?(?:github\/)?workflows\/[^/]+\.ya?ml$/u;
|
|
15
18
|
const DELIVERY = /(?:^|\/)(?:deploy|scripts|bin)\//u;
|
|
16
19
|
const DOCKER = /(?:^|\/)(?:Dockerfile(?:\.[^/]*)?|(?:docker-)?compose(?:[.-][^/]*)?\.ya?ml)$/iu;
|
|
17
|
-
const
|
|
20
|
+
const SECRET_REFERENCE_NAME = /(?:secrets?\.env|\.env)(?:\.[\w.-]+)?/;
|
|
21
|
+
const SECRET_REFERENCE_NIVO = /\.nivo-lite(?:[/\\][\w./\\-]*)?/;
|
|
22
|
+
const SECRET_REFERENCE_BEFORE = /(?:^|[\s/\\'"`(=])/;
|
|
23
|
+
const SECRET_REFERENCE_AFTER = /(?=$|[\s/\\'"`),;])/;
|
|
24
|
+
const SECRET_REFERENCE_SOURCE = `${SECRET_REFERENCE_BEFORE.source}(?:${SECRET_REFERENCE_NAME.source}|${SECRET_REFERENCE_NIVO.source})${SECRET_REFERENCE_AFTER.source}`;
|
|
25
|
+
const SECRET_REFERENCE = new RegExp(SECRET_REFERENCE_SOURCE, 'iu');
|
|
18
26
|
const EXAMPLE_REFERENCE = /(?:secrets?\.env|\.env)\.(?:example|sample|template)(?=$|[^\w.-])/giu;
|
|
19
|
-
const
|
|
27
|
+
const LOAD_SHELL_COMMAND = /(?:^|[\s;&|('"`])(?:source|cat|tac|less|more|head|tail|grep|egrep|fgrep|awk|gawk|sed|base64)\b/iu;
|
|
28
|
+
const LOAD_WINDOWS_COMMAND = /(?:^|[\s;&|('"`])(?:Get-Content|gc|Import-DotEnv|env-cmd|load_dotenv|loadEnvFile)\b/iu;
|
|
29
|
+
const LOAD_READ_CALL = /(?:^|[\s;&|('"`])(?:readFile(?:Sync)?|readTextFile(?:Sync)?|ReadAllText|ReadAllLines|dotenv\s*\.\s*(?:config|parse))\b/iu;
|
|
30
|
+
const LOAD_FILE_READ = /\b(?:File|IO)\s*\.\s*(?:read|readlines)\b/iu;
|
|
31
|
+
const LOAD_PYTHON_READ = /\.\s*read_(?:text|bytes)\s*\(/iu;
|
|
32
|
+
const LOAD_DOT_SOURCE = /(?:^|[\s;&|])\.[\t ]+/iu;
|
|
33
|
+
const LOAD_REDIRECT = /(?<!<)<(?!<)/iu;
|
|
34
|
+
const LOAD_PATTERNS = [LOAD_SHELL_COMMAND, LOAD_WINDOWS_COMMAND, LOAD_READ_CALL, LOAD_FILE_READ, LOAD_PYTHON_READ, LOAD_DOT_SOURCE, LOAD_REDIRECT];
|
|
35
|
+
const isLoad = (line) => LOAD_PATTERNS.some((pattern) => pattern.test(line));
|
|
20
36
|
const READ_CALL = /\b(?:readFile(?:Sync)?|readTextFile(?:Sync)?|ReadAllText|ReadAllLines|load_dotenv|loadEnvFile|dotenv\s*\.\s*(?:config|parse))\s*\(/giu;
|
|
21
37
|
const TRACE_COMMAND = /(?:^|[\s;&|('"`]|#!)(?:set|\/?(?:[\w.-]+\/)*(?:bash|sh|dash|zsh|ksh))\s+/giu;
|
|
22
38
|
const SHELL_OPTIONS = /\bSHELLOPTS\s*=\s*['"]?([^\s;'"]*)/giu;
|
|
@@ -26,7 +42,7 @@ const SERVICE_ROLE_NAME = /(?:^|[^a-z0-9])(?:[a-z0-9]+[_-])*service[_-]?role(?:[
|
|
|
26
42
|
function executableText(text) {
|
|
27
43
|
return text.replace(/\/\*[\s\S]*?\*\//gu, '').split(/\r?\n/u)
|
|
28
44
|
.filter((line) => !/^\s*(?:#(?!!)|\/\/)/u.test(line)).join('\n')
|
|
29
|
-
.replace(/
|
|
45
|
+
.replace(/[\\`]\r?\n/gu, ' ');
|
|
30
46
|
}
|
|
31
47
|
|
|
32
48
|
const hasSecretReference = (text) => SECRET_REFERENCE.test(text.replace(EXAMPLE_REFERENCE, 'example-file'));
|
|
@@ -37,7 +53,7 @@ function callArguments(text, from) {
|
|
|
37
53
|
for (let at = from; at < Math.min(text.length, from + 1024); at += 1) {
|
|
38
54
|
const character = text[at];
|
|
39
55
|
if (character === '\\') { at += 1; continue; }
|
|
40
|
-
if (quote) { if (character === quote) quote = ''; continue; }
|
|
56
|
+
if (quote) { if (character === quote) { quote = ''; } continue; }
|
|
41
57
|
if (['\'', '"', '`'].includes(character)) { quote = character; continue; }
|
|
42
58
|
if (character === '(') depth += 1;
|
|
43
59
|
if (character === ')' && --depth === 0) return text.slice(from, at);
|
|
@@ -81,7 +97,7 @@ function plaintextLoad(text) {
|
|
|
81
97
|
const lines = text.split(/\r?\n/u);
|
|
82
98
|
const aliases = secretAliases(lines);
|
|
83
99
|
const secretReference = (value) => hasSecretReference(value) || aliases.some((alias) => alias.test(value));
|
|
84
|
-
return lines.some((line) =>
|
|
100
|
+
return lines.some((line) => isLoad(line) && secretReference(line)) || multilineLoad(text, secretReference) || pythonLoad(text);
|
|
85
101
|
}
|
|
86
102
|
|
|
87
103
|
function tracingOptions(source) {
|
|
@@ -120,7 +136,7 @@ function integrationOwns(file, resolver) {
|
|
|
120
136
|
}
|
|
121
137
|
|
|
122
138
|
function isSource(file) {
|
|
123
|
-
if (
|
|
139
|
+
if (isSecretFile(file) || EXCLUDED_TREE.test(file) || TEST_FILE.test(file)) return false;
|
|
124
140
|
return SOURCE.test(file) || WORKFLOW.test(file) || DOCKER.test(file)
|
|
125
141
|
|| file.endsWith('/package.json') || file === 'package.json'
|
|
126
142
|
|| (DELIVERY.test(file) && !/\.[a-z0-9]+$/iu.test(file));
|
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
// exists only at be/apps/<service>/ (fe/apps/<app>/Dockerfile is a front end's);
|
|
6
6
|
// R164 HFS_IMAGE_UNPINNED in a multi-service product every component image of application-stacks.yaml is a digest or an exact
|
|
7
7
|
// version (x.y.z), never a missing tag, `latest`, a branch word or a major/minor-only tag;
|
|
8
|
-
// R165 HFS_SERVICE_STACK_DECLARATION each api or worker app of a multi-service product is a `role: service` component of the stack
|
|
8
|
+
// R165 HFS_SERVICE_STACK_DECLARATION each api or worker app of a multi-service product is a `role: service` component of the stack,
|
|
9
|
+
// named after the app or mapped to it by `environments.<env>.deploy.images.<component>.app`;
|
|
9
10
|
// R166 HFS_EVENT_CONTRACT the async contract is vendored: `be/contracts/<service>/events.json` equals the provider's
|
|
10
11
|
// `apps/<service>/src/events.ts` table (an event may declare `compensates: "<event>"`), every event a
|
|
11
12
|
// consumer's `apps/<app>/src/consumes.ts` names exists in that snapshot at the same version;
|
|
@@ -16,6 +17,7 @@
|
|
|
16
17
|
import path from 'node:path';
|
|
17
18
|
import { fileURLToPath } from 'node:url';
|
|
18
19
|
import { findStackDeclaration } from '../../lib/stack-declaration.mjs';
|
|
20
|
+
import { deployImageApps } from '../stack-deploy-config.mjs';
|
|
19
21
|
import { loadTypescript } from '../../lib/package-at.mjs';
|
|
20
22
|
import { propertyText } from '../../lib/ts-ast.mjs';
|
|
21
23
|
import { folded, readEventClasses, snapshotText } from '../../lib/event-contract.mjs';
|
|
@@ -105,7 +107,17 @@ function imagePinFindings({ repoRoot, repo }) {
|
|
|
105
107
|
|
|
106
108
|
// ------------------------------------------------------------------------------------------------ R165 stack declaration
|
|
107
109
|
|
|
108
|
-
/**
|
|
110
|
+
/**
|
|
111
|
+
* The component names that stand for `app`: the app's own name, then every `environments.<env>.deploy.images` key whose `app` is
|
|
112
|
+
* the app (the one tie between component and app the declaration states, read by scripts/hfs/stack-deploy-config.mjs). A product
|
|
113
|
+
* whose deployed service is named after its stack (`shop-api: {app: api}`) keeps that name; renaming it would rename the live
|
|
114
|
+
* service and put the short app alias on a shared network (R294).
|
|
115
|
+
*/
|
|
116
|
+
function componentNamesOf(doc, app) {
|
|
117
|
+
return [...new Set([app, ...deployImageApps(doc).filter((tie) => tie.app === app).map((tie) => tie.service)])];
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Findings of R165: each service app of a multi-service product is a `role: service` component, named after it or mapped to it by deploy.images. */
|
|
109
121
|
function serviceStackFindings({ repoRoot, repo }) {
|
|
110
122
|
const services = servicesOf(repo);
|
|
111
123
|
if (services.length < 2) return [];
|
|
@@ -115,9 +127,11 @@ function serviceStackFindings({ repoRoot, repo }) {
|
|
|
115
127
|
const file = '.starcistacks/application-stacks.yaml';
|
|
116
128
|
const findings = [];
|
|
117
129
|
for (const app of services) {
|
|
118
|
-
const
|
|
119
|
-
|
|
120
|
-
|
|
130
|
+
const names = componentNamesOf(declaration.doc, app.name);
|
|
131
|
+
const declared = names.filter((name) => components[name] !== null && typeof components[name] === 'object');
|
|
132
|
+
if (declared.some((name) => components[name].role === 'service')) continue;
|
|
133
|
+
if (!declared.length) findings.push(found(SERVICE_STACK_DECLARATION, file, `${file} declares no component for the ${app.kind} app be/apps/${app.name}: none named \`${app.name}\` and none mapped to it by \`environments.<env>.deploy.images.<component>.app: ${app.name}\`; every service of a multi-service product is a \`role: service\` component (own image at an exact version, built from be/apps/${app.name}/Dockerfile).`, { app: app.name }));
|
|
134
|
+
else findings.push(found(SERVICE_STACK_DECLARATION, file, `${file} ${declared.map((name) => 'components.' + name).join(', ')} is the stack's face of be/apps/${app.name} and must be \`role: service\`, not \`${String(components[declared[0]].role)}\`.`, { app: app.name, components: declared }));
|
|
121
135
|
}
|
|
122
136
|
return findings;
|
|
123
137
|
}
|
|
@@ -13,7 +13,10 @@ const SCRIPTS_DIR = /(?:^|\/)scripts\//;
|
|
|
13
13
|
const PACKAGE_JSON = /(?:^|\/)package\.json$/;
|
|
14
14
|
const SOURCE_FILE = /(?:^|\/)[^/.]+$|\.(?:sh|bash|zsh|mjs|cjs|js|ts|mts|py|ya?ml)$/;
|
|
15
15
|
const TEST_FILE = /\.(?:spec|test)\.[cm]?[jt]s$/;
|
|
16
|
-
const
|
|
16
|
+
const CREDENTIAL_ENV_FILE = /(?:^|\/)(?:secrets?\.env|\.env)(?:\.[^/]*)?/i;
|
|
17
|
+
const CREDENTIAL_SECRETS_DIR = /(?:^|\/)secrets\//i;
|
|
18
|
+
const CREDENTIAL_ENC_FILE = /\.enc$/i;
|
|
19
|
+
const isCredentialFile = (file) => CREDENTIAL_ENV_FILE.test(file) || CREDENTIAL_SECRETS_DIR.test(file) || CREDENTIAL_ENC_FILE.test(file);
|
|
17
20
|
const VERB_FILE = /^(render|provision|converge|verify|prune|rollback|release|backup|prepare|doctor|jobs|public)\.(?:mjs|js|cjs|sh)$/;
|
|
18
21
|
const COMMENT_LINE = /^[ \t]*(?:#|\/\/).*$/gm;
|
|
19
22
|
const LINE_CONTINUATION = /\\\r?\n\s*/g;
|
|
@@ -57,7 +60,7 @@ function sourceText(file, raw) {
|
|
|
57
60
|
}
|
|
58
61
|
|
|
59
62
|
function deploySource(file) {
|
|
60
|
-
if (MANAGED_WORKFLOW.test(file) ||
|
|
63
|
+
if (MANAGED_WORKFLOW.test(file) || isCredentialFile(file) || TEST_FILE.test(file)) return false;
|
|
61
64
|
if (PACKAGE_JSON.test(file) || WORKFLOW.test(file)) return true;
|
|
62
65
|
return (DEPLOY_DIR.test(file) || SCRIPTS_DIR.test(file)) && SOURCE_FILE.test(file);
|
|
63
66
|
}
|
|
@@ -14,8 +14,8 @@ import { stacksDeploymentFindings } from './stacks-runtime.mjs';
|
|
|
14
14
|
|
|
15
15
|
const mapping = value => plain(value) ? value : {};
|
|
16
16
|
const present = value => typeof value === 'string' && Boolean(value.trim());
|
|
17
|
-
const VERSION = /^[1-9]
|
|
18
|
-
const VERSIONED_SECRET = /-v[1-9]
|
|
17
|
+
const VERSION = /^[1-9]\d*$/;
|
|
18
|
+
const VERSIONED_SECRET = /-v[1-9]\d*$/;
|
|
19
19
|
const SENSITIVE_KEY = /password|passwd|secret|token|credential|private[_-]?key|api[_-]?key|service[_-]?role|(?:(?:^|_)key$)/i;
|
|
20
20
|
const SOURCE = /^be\/(?:apps|src)\/.*\.[cm]?[jt]sx?$/;
|
|
21
21
|
const TEST_SOURCE = /(?:\.(?:spec|test)|-spec)\.[cm]?[jt]sx?$|\/tests\//;
|
|
@@ -124,7 +124,7 @@ function traefikHosts(rule) {
|
|
|
124
124
|
function positiveHostCall(prefix) {
|
|
125
125
|
const scopes = [false];
|
|
126
126
|
let pending = false;
|
|
127
|
-
for (const token of prefix.match(/"[^"]*"|'[^']*'|`[^`]*`|[()!]|[A-Za-z_]
|
|
127
|
+
for (const token of prefix.match(/"[^"]*"|'[^']*'|`[^`]*`|[()!]|[A-Za-z_]\w*|\S/g) ?? []) {
|
|
128
128
|
if (token === '!') { pending = !pending; continue; }
|
|
129
129
|
if (token === '(') scopes.push(scopes.at(-1) !== pending);
|
|
130
130
|
if (token === ')' && scopes.length > 1) scopes.pop();
|
|
@@ -139,7 +139,7 @@ function labelHosts(service, family) {
|
|
|
139
139
|
if (family === 'traefik' && labels.some(([key, value]) => key === 'traefik.enable' && String(value) === 'false')) return hosts;
|
|
140
140
|
for (const [key, value] of labels) {
|
|
141
141
|
if (family === 'traefik' && key.startsWith('traefik.http.routers.') && key.endsWith('.rule')) hosts.push(...traefikHosts(value));
|
|
142
|
-
if (family === 'caddy-docker-proxy' && /^caddy(?:_
|
|
142
|
+
if (family === 'caddy-docker-proxy' && /^caddy(?:_\d+)?$/.test(key)) hosts.push(...String(value).split(/[\s,]+/).map(hostOf).filter(Boolean));
|
|
143
143
|
}
|
|
144
144
|
return hosts;
|
|
145
145
|
}
|
|
@@ -282,7 +282,7 @@ function positiveMonitor(value) {
|
|
|
282
282
|
const units = value.match(/[a-z]+/g) ?? [], quantities = value.split(/[a-z]+/);
|
|
283
283
|
if (quantities.pop() !== '' || !units.length) return false;
|
|
284
284
|
return units.every(unit => ['ns', 'us', 'ms', 's', 'm', 'h'].includes(unit))
|
|
285
|
-
&& quantities.every(quantity =>
|
|
285
|
+
&& quantities.every(quantity => /^\d+(?:\.\d+)?$/.test(quantity)) && /[1-9]/.test(value);
|
|
286
286
|
}
|
|
287
287
|
|
|
288
288
|
function updatePolicyIssues(id, service, add) {
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// staged-specs.mjs - BE_UNIT_SPEC_NOT_STAGED (R47): the commit that adds a back-end logic file adds its unit spec beside it. A new
|
|
2
|
+
// file `be/<path>/<name>.<role>.ts` that the coverage scope measures (scripts/hfs/coverage-scope.mjs isMeasured: a role of
|
|
3
|
+
// ruleParams.be.logicRoles below a measured root, outside its none folders) needs `<name>.<role>.spec.ts` in the same index.
|
|
4
|
+
// `starci app hygiene` judges the files the index adds (packages/hfs/sync/hygiene.mjs, run by the managed pre-commit hook), so a
|
|
5
|
+
// coding agent cannot commit the code first and its spec afterwards. A file that already exists is the coverage gate's.
|
|
6
|
+
import { isMeasured } from '../coverage-scope.mjs';
|
|
7
|
+
|
|
8
|
+
const SPEC_NOT_STAGED = 'BE_UNIT_SPEC_NOT_STAGED';
|
|
9
|
+
const BE = 'be/';
|
|
10
|
+
|
|
11
|
+
/** The unit spec beside an app-relative `<name>.<role>.ts`: `<name>.<role>.spec.ts`. */
|
|
12
|
+
const siblingSpec = (file) => file.replace(/\.ts$/u, '.spec.ts');
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The findings `{ file, code, message }` of the app-relative files the index adds (`added`) whose sibling spec is not among the
|
|
16
|
+
* paths the index holds (`indexed`, a Set). `manifest` is the slot manifest the coverage scope reads.
|
|
17
|
+
*/
|
|
18
|
+
export function stagedSpecFindings(manifest, added, indexed) {
|
|
19
|
+
return added
|
|
20
|
+
.filter((file) => file.startsWith(BE) && !file.endsWith('.spec.ts') && isMeasured(manifest, file.slice(BE.length)) && !indexed.has(siblingSpec(file)))
|
|
21
|
+
.map((file) => ({
|
|
22
|
+
file,
|
|
23
|
+
code: SPEC_NOT_STAGED,
|
|
24
|
+
message: `is a new back-end logic file the unit coverage measures at 100 per file, staged without ${siblingSpec(file).slice(file.lastIndexOf('/') + 1)} beside it; write the spec in the same change (starci gate read, code and spec, starci gate run --tests) and stage both`,
|
|
25
|
+
}));
|
|
26
|
+
}
|
|
@@ -141,12 +141,12 @@ const provenanceEnabled = (value) => asserted(value) || typeof value === 'string
|
|
|
141
141
|
|
|
142
142
|
function imageActionEvidence(step) {
|
|
143
143
|
const action = step.uses ?? '';
|
|
144
|
-
if (
|
|
144
|
+
if (action.startsWith('docker/build-push-action@')) {
|
|
145
145
|
return { sbom: asserted(step.with?.sbom), provenance: provenanceEnabled(step.with?.provenance) };
|
|
146
146
|
}
|
|
147
147
|
return {
|
|
148
|
-
sbom:
|
|
149
|
-
provenance:
|
|
148
|
+
sbom: action.startsWith('actions/attest-sbom@') && Boolean(step.with?.['sbom-path']),
|
|
149
|
+
provenance: action.startsWith('actions/attest-build-provenance@') && Boolean(step.with?.['subject-path'] || step.with?.['subject-digest']),
|
|
150
150
|
};
|
|
151
151
|
}
|
|
152
152
|
|
|
@@ -14,7 +14,7 @@ function mergeProperty(before, after) {
|
|
|
14
14
|
/** Every possible spread owner must be known before its property provenance is admitted. */
|
|
15
15
|
function spreadProperty(state, expression, name, seen, resolve) {
|
|
16
16
|
const choices = resolve(state, expression, seen).map(owner => ownerProperty(state, owner, name, seen, resolve));
|
|
17
|
-
if (!choices.length || choices.
|
|
17
|
+
if (!choices.length || choices.includes(null)) return null;
|
|
18
18
|
return { values: choices.flatMap(choice => choice.values), missing: choices.some(choice => choice.missing) };
|
|
19
19
|
}
|
|
20
20
|
|
|
@@ -12,7 +12,10 @@ import { returnedExpressions, valuesOf } from './unused-symbol-flow.mjs';
|
|
|
12
12
|
|
|
13
13
|
export const HFS_UNUSED_SYMBOL = 'HFS_UNUSED_SYMBOL';
|
|
14
14
|
const SOURCE = /\.[cm]?[jt]sx?$/u;
|
|
15
|
-
const
|
|
15
|
+
const EXCLUDED_DIR = /(?:^|\/)(?:node_modules|__generated__|_generated|dist|tests?|__tests__|test-support)\//u;
|
|
16
|
+
const EXCLUDED_TEST_FILE = /(?:\.(?:spec|test)|-spec)\.[cm]?[jt]sx?$/u;
|
|
17
|
+
const EXCLUDED_DECLARATION_FILE = /\.d\.[cm]?ts$/u;
|
|
18
|
+
const isExcluded = (file) => EXCLUDED_DIR.test(file) || EXCLUDED_TEST_FILE.test(file) || EXCLUDED_DECLARATION_FILE.test(file);
|
|
16
19
|
const CATALOG = /(?:\.messages|\.catalog)\.[cm]?[jt]s$/u;
|
|
17
20
|
const JSON_CATALOG = /(?:^|\/)(?:messages|locales)\/[^/]+\.json$|\.catalog\.json$/u;
|
|
18
21
|
const QUEUE_NAME = /(?:^QUEUE(?:_NAME)?$|_QUEUE(?:_NAME)?$)/u;
|
|
@@ -29,7 +32,7 @@ function compilerAt(repoRoot, supplied) {
|
|
|
29
32
|
function sourceFiles(ts, repoRoot, files) {
|
|
30
33
|
const sources = new Map();
|
|
31
34
|
for (const file of files) {
|
|
32
|
-
if (!SOURCE.test(file) ||
|
|
35
|
+
if (!SOURCE.test(file) || isExcluded(file) || !/(?:^|\/)src\//u.test(file)) continue;
|
|
33
36
|
const text = readText(repoRoot, file);
|
|
34
37
|
if (text !== null) sources.set(slash(path.resolve(repoRoot, file)), ts.createSourceFile(path.resolve(repoRoot, file), text, ts.ScriptTarget.Latest, true));
|
|
35
38
|
}
|
|
@@ -259,7 +262,7 @@ function sourceReads(state, sources) {
|
|
|
259
262
|
}
|
|
260
263
|
|
|
261
264
|
function jsonCandidates(state, files) {
|
|
262
|
-
for (const file of files.filter(file => JSON_CATALOG.test(file) && !
|
|
265
|
+
for (const file of files.filter(file => JSON_CATALOG.test(file) && !isExcluded(file))) {
|
|
263
266
|
let json;
|
|
264
267
|
try { json = JSON.parse(readText(state.repoRoot, file)); } catch { continue; }
|
|
265
268
|
const leaves = (value, prefix = '') => {
|
|
@@ -316,7 +319,8 @@ export function unusedSymbolFindings({ repoRoot, files, repo = {}, isRoot = fals
|
|
|
316
319
|
const findings = new Map();
|
|
317
320
|
for (const candidate of state.candidates) {
|
|
318
321
|
if (candidate.used || candidate.kind === 'catalog key' && catalogRead(state, candidate, liveCodes) || !inScope(candidate.path)) continue;
|
|
319
|
-
const
|
|
322
|
+
const extra = { ...candidate };
|
|
323
|
+
delete extra.used;
|
|
320
324
|
findings.set(`${candidate.path}:${candidate.name}`, found(HFS_UNUSED_SYMBOL, candidate.path, `${candidate.path} declares unused ${candidate.kind} ${candidate.name}; remove it or read it in production source (declarations, metadata maps and tests do not count).`, extra));
|
|
321
325
|
}
|
|
322
326
|
return [...findings.values(), ...ledgerFindings.filter(finding => inScope(finding.path))].sort((a, b) => a.path.localeCompare(b.path) || a.line - b.line || a.name.localeCompare(b.name));
|
|
@@ -75,7 +75,7 @@ export function declaredSealedMembers(repoRoot) {
|
|
|
75
75
|
|
|
76
76
|
function placeholderValue(key) {
|
|
77
77
|
const generated = randomUUID();
|
|
78
|
-
if (
|
|
78
|
+
if (key.endsWith('_URL') && key.slice(0, -4).endsWith('DB')) return `postgresql://app_be:${generated}@db.invalid:5432/app`;
|
|
79
79
|
if (key.endsWith('_URL')) return `https://example.invalid/${generated}`;
|
|
80
80
|
return generated;
|
|
81
81
|
}
|
|
@@ -163,7 +163,7 @@ function bootstrapEntries(values) {
|
|
|
163
163
|
const entries = Object.entries(values);
|
|
164
164
|
if (!entries.length) throw new Error('Bootstrap requires at least one value');
|
|
165
165
|
for (const [name, value] of entries) {
|
|
166
|
-
if (!/^[A-Za-z_]
|
|
166
|
+
if (!/^[A-Za-z_]\w*$/.test(name) || name.startsWith('sops_') || name.endsWith('_unencrypted')) {
|
|
167
167
|
throw new Error('Bootstrap contains an invalid or reserved dotenv key');
|
|
168
168
|
}
|
|
169
169
|
if (typeof value !== 'string' || !value.length) throw new Error('Bootstrap values must be nonempty strings');
|
|
@@ -61,7 +61,7 @@ export function privateDirectory(repoRoot, directory) {
|
|
|
61
61
|
const missing = [];
|
|
62
62
|
for (let at = directory; !fs.existsSync(at); at = path.dirname(at))
|
|
63
63
|
missing.push(at);
|
|
64
|
-
for (const at of missing.
|
|
64
|
+
for (const at of missing.toReversed()) fs.mkdirSync(at, { mode: 0o700 });
|
|
65
65
|
for (let at = directory; ; at = path.dirname(at)) {
|
|
66
66
|
const stat = fs.lstatSync(at);
|
|
67
67
|
if (!stat.isDirectory() || stat.isSymbolicLink())
|
|
@@ -19,7 +19,7 @@ export function defaultSops(
|
|
|
19
19
|
env = process.env,
|
|
20
20
|
repoRoot = invocationDir(),
|
|
21
21
|
platform = process.platform,
|
|
22
|
-
tools,
|
|
22
|
+
tools = undefined,
|
|
23
23
|
) {
|
|
24
24
|
const { sops: binary } = publicStorage(() => secretTools(env, tools));
|
|
25
25
|
const selectedEnv = ageIdentityEnv(repoRoot, env, tools);
|