@starci/hfs 4.2.16 → 4.3.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 +21 -0
- package/README.md +7 -0
- package/package.json +1 -1
- package/runtime/knowledge/hfs/canon-pins.yaml +4 -4
- package/runtime/knowledge/hfs/rules.yaml +41 -13
- package/runtime/knowledge/hfs/slots.yaml +99 -43
- package/runtime/knowledge/patterns/be/api.yaml +64 -1
- package/runtime/knowledge/patterns/be/cli.yaml +23 -5
- package/runtime/knowledge/patterns/be/webhooks.yaml +187 -32
- package/runtime/knowledge/patterns/fe/folder.yaml +3 -0
- package/runtime/modules/kernel/failure-codes.yaml +15 -4
- package/runtime/scripts/api/git/cat-file.mjs +5 -0
- package/runtime/scripts/api/sops/resolve-sops.mjs +7 -0
- package/runtime/scripts/api/sops/seal.mjs +1 -1
- package/runtime/scripts/hfs/architecture/backend-app-files.mjs +110 -0
- package/runtime/scripts/hfs/architecture/backend.mjs +20 -3
- package/runtime/scripts/hfs/architecture/background-unowned.mjs +157 -41
- package/runtime/scripts/hfs/architecture/canon-clones.mjs +240 -0
- package/runtime/scripts/hfs/architecture/canon-jsx-clones.mjs +143 -0
- package/runtime/scripts/hfs/architecture/clones.mjs +17 -8
- package/runtime/scripts/hfs/architecture/connection-map.mjs +14 -12
- package/runtime/scripts/hfs/architecture/context-map.mjs +25 -14
- package/runtime/scripts/hfs/architecture/context-owner.mjs +1 -2
- package/runtime/scripts/hfs/architecture/dead-exports.mjs +14 -9
- package/runtime/scripts/hfs/architecture/frontend-block-render.mjs +104 -0
- package/runtime/scripts/hfs/architecture/frontend-world-render.mjs +30 -11
- package/runtime/scripts/hfs/architecture/i18n-keys.mjs +16 -3
- package/runtime/scripts/hfs/architecture/machine-ast.mjs +17 -0
- package/runtime/scripts/hfs/architecture/module-per-transport.mjs +21 -12
- package/runtime/scripts/hfs/architecture/register-once.mjs +13 -5
- package/runtime/scripts/hfs/architecture/required-files.mjs +2 -1
- package/runtime/scripts/hfs/architecture/staged-consumers.mjs +37 -0
- package/runtime/scripts/hfs/architecture/supabase-ast.mjs +3 -6
- package/runtime/scripts/hfs/architecture/supabase-be.mjs +1 -6
- package/runtime/scripts/hfs/architecture/tiers.mjs +8 -1
- package/runtime/scripts/hfs/architecture/typescript.mjs +1 -8
- package/runtime/scripts/hfs/architecture/unit-spec-providers.mjs +1 -11
- package/runtime/scripts/hfs/check.mjs +18 -26
- package/runtime/scripts/hfs/manifest-shape.mjs +27 -2
- package/runtime/scripts/hfs/path-findings.mjs +32 -2
- package/runtime/scripts/hfs/rules/database-migrations.mjs +36 -35
- package/runtime/scripts/hfs/rules/database.mjs +2 -2
- package/runtime/scripts/hfs/rules/edition.mjs +5 -5
- package/runtime/scripts/hfs/rules/proof-commands.mjs +2 -0
- package/runtime/scripts/hfs/rules/secrets.mjs +13 -0
- package/runtime/scripts/hfs/rules/stacks-runtime.mjs +128 -0
- package/runtime/scripts/hfs/rules/stacks.mjs +13 -8
- package/runtime/scripts/hfs/secret-document.mjs +97 -0
- package/runtime/scripts/hfs/secret-errors.mjs +20 -0
- package/runtime/scripts/hfs/secret-files.mjs +124 -0
- package/runtime/scripts/hfs/secret-init.mjs +192 -0
- package/runtime/scripts/hfs/secret-output.mjs +100 -0
- package/runtime/scripts/hfs/secret-sops.mjs +98 -0
- package/runtime/scripts/hfs/secret-store.mjs +161 -0
- package/runtime/scripts/hfs/secret.mjs +135 -195
- package/runtime/scripts/hfs/slot-imports.mjs +3 -1
- package/runtime/scripts/hfs/slot-required.mjs +13 -0
- package/runtime/scripts/hfs/slot-semantic-problems.mjs +2 -1
- package/runtime/scripts/hfs/tree-findings.mjs +22 -0
- package/runtime/scripts/lib/roots.mjs +59 -0
- package/runtime/scripts/lib/swarm-stack.mjs +139 -0
- package/runtime/scripts/lib/ts-analysis.mjs +20 -0
- package/scaffold/add-cli-lite.mjs +10 -11
- package/scaffold/add-inbox-lite.mjs +15 -84
- package/scaffold/add-plan.mjs +183 -0
- package/scaffold/add-table.mjs +44 -5
- package/scaffold/add.mjs +33 -166
- package/scaffold/app.mjs +5 -5
- package/scaffold/image.mjs +2 -2
- package/src/main.mjs +12 -6
- package/sync/index.mjs +23 -16
- package/templates/app/gitignore +1 -0
- package/templates/app/gitignore-lite +1 -0
- package/templates/app/hooks/husky/pre-commit +7 -12
- package/templates/app/skeleton/.sops.yaml +1 -1
- package/templates/app/skeleton-lite/.sops.yaml +1 -1
- package/templates/app/skeleton-lite/.starcistacks/application-stacks.yaml +16 -0
- package/templates/app/skeleton-lite/.starciwork/workspace.yaml +9 -0
- package/templates/app/skeleton-lite/README.md +4 -2
- package/templates/app/starciwork.gitignore-lite +6 -0
- package/templates/be/inbox/database-client.spec.ts.tpl +15 -20
- package/templates/be/inbox/database-client.ts.tpl +7 -22
- package/templates/be/inbox/service.spec.ts.tpl +16 -20
- package/templates/be/inbox/service.ts.tpl +6 -10
- package/templates/be/inbox/sql.ts.tpl +2 -2
- package/templates/be/inbox/webhook-door.spec.ts.tpl +5 -5
- package/templates/be/inbox/webhook-door.ts.tpl +4 -3
- package/templates/be/patterns/webhooks/webhook.door.spec.ts.tpl +3 -3
- package/templates/be/patterns/webhooks/webhook.door.ts.tpl +3 -3
- package/templates/be/skeleton/apps/__app__/src/__app__.env.ts +21 -0
- package/templates/be/skeleton/apps/__app__/src/__app__.options.ts +1 -1
- package/templates/be/skeleton/apps/__app__/src/app.module.ts +7 -39
- package/templates/be/skeleton/apps/__app__/src/compose/platform.ts +42 -0
- package/templates/be/skeleton/apps/__app__/src/main.ts +3 -9
- package/templates/be/skeleton/apps/cli/src/cli.env.ts +13 -0
- package/templates/be/skeleton/apps/cli/src/cli.options.ts +6 -8
- package/templates/be/skeleton/apps/cli/src/main.ts +3 -2
- package/templates/be/skeleton/src/modules/platform/config/env-source.config.ts +6 -6
- package/templates/be/skeleton-lite/apps/api/src/api.env.ts +25 -0
- package/templates/be/skeleton-lite/apps/api/src/api.options.ts +1 -1
- package/templates/be/skeleton-lite/apps/api/src/app.module.ts +6 -42
- package/templates/be/skeleton-lite/apps/api/src/compose/platform.ts +45 -0
- package/templates/be/skeleton-lite/apps/api/src/main.ts +4 -11
- package/templates/be/skeleton-lite/apps/cli/src/cli.env.ts +17 -0
- package/upgrade/file-state.mjs +68 -0
- package/upgrade/index.mjs +7 -56
- package/runtime/scripts/api/git/log.mjs +0 -5
- package/templates/app/skeleton-lite/.starciwork/shell/index.yaml +0 -55
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
// HFS_SLOT_REQUIRED_MISSING a file or directory a required slot (or an instance of one) must contain
|
|
17
17
|
// HFS_MIN_INSTANCES fewer instances of a slot than minInstances
|
|
18
18
|
// HFS_PLAINTEXT_SECRET (R06, rules/secrets.mjs) a plaintext secret file or value in a tracked file; a `.enc` that is no sops envelope
|
|
19
|
-
// HFS_STACKS_SHAPE (R10, rules/stacks.mjs)
|
|
19
|
+
// HFS_STACKS_SHAPE (R10, rules/stacks.mjs) invalid stack shape, Swarm runtime policy, host crontab installation or lite backing duplication
|
|
20
20
|
// HFS_CI_MISSING_CANON (R13, rules/pipeline.mjs) CI without the pinned `starci app check`, pre-push without typecheck or lint
|
|
21
21
|
// HFS_DEP_VERSION_SKEW (R14, rules/deps.mjs) a dependency at two versions in the workspace, or a nested copy in the lockfile
|
|
22
22
|
// HFS_CONTRACT_SNAPSHOT_DRIFT (R23, rules/contract.mjs) an uncommitted back-end snapshot, or a front-end copy that differs from it
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
// FE_STACK_FORBIDDEN (rules/fe-stack.mjs) a front-end manifest (the fe side's, or the app root's minus the back end's own libraries)
|
|
26
26
|
// declares a package knowledge/hfs/fe-stack.yaml forbids
|
|
27
27
|
// HFS_LITE_SECRET_CUSTODY (R220, rules/supabase-secrets.mjs) under edition lite: secret material or a forbidden custody path; the R06 scan itself runs for every edition
|
|
28
|
-
// HFS_EDITION_FORBIDDEN_PRESENT (R212, rules/edition.mjs) under edition lite: an e2e, contract, stack or browser script, or a
|
|
28
|
+
// HFS_EDITION_FORBIDDEN_PRESENT (R212, rules/edition.mjs) under edition lite: an e2e, contract, test-stack or browser script, or a
|
|
29
29
|
// dependency of self-hosted infrastructure (test containers, fault proxy, BullMQ, Kafka, Redis)
|
|
30
30
|
// DB_MIGRATION_SHAPE ... DB_TYPES_DRIFT (R213-R216, rules/database.mjs, async: passed in as extraFindings) the Supabase migrations, policies, definer functions, buckets, config.toml and generated types
|
|
31
31
|
// BE_SPEC_PLACEMENT (R102, rules/spec-placement.mjs) a spec or test file outside the four test layers, scripts/ and tools/ included
|
|
@@ -67,14 +67,15 @@ import { diff as gitDiff } from '../api/git/diff.mjs';
|
|
|
67
67
|
import { mergeBase } from '../api/git/merge-base.mjs';
|
|
68
68
|
import { gitOutputOf } from '../lib/git.mjs';
|
|
69
69
|
import { posixPath } from '../lib/path-key.mjs';
|
|
70
|
-
import {
|
|
70
|
+
import { treeFindings } from './tree-findings.mjs';
|
|
71
71
|
import { contractFindings } from './rules/contract.mjs';
|
|
72
72
|
import { appRootFindings } from './rules/app-root.mjs';
|
|
73
73
|
import { frontendFindings } from './rules/frontend-tree.mjs';
|
|
74
74
|
import { lintSuppressionFindings } from './rules/lint-suppression.mjs';
|
|
75
75
|
import { repoLocalCheckFindings } from './rules/repo-local-checks.mjs';
|
|
76
76
|
import { supabaseSecretFindings } from './rules/supabase-secrets.mjs';
|
|
77
|
-
import {
|
|
77
|
+
import { untrackedSecretFindings } from './rules/secrets.mjs';
|
|
78
|
+
import { liteStarciworkFinding, pathFindings } from './path-findings.mjs';
|
|
78
79
|
import { onLintSurface } from './architecture/surface.mjs';
|
|
79
80
|
import { checkAppRoot, trackedTreeView } from './architecture/hfs.mjs';
|
|
80
81
|
import { testTopologyFindings } from './rules/test-topology.mjs';
|
|
@@ -83,6 +84,7 @@ import { editionFindings } from './rules/edition.mjs';
|
|
|
83
84
|
import { feStackFindings } from './rules/fe-stack.mjs';
|
|
84
85
|
import { byCodeUnit } from '../lib/list.mjs';
|
|
85
86
|
import { pinFindings } from './pin-findings.mjs';
|
|
87
|
+
import { requiredFileApplies } from './slot-required.mjs';
|
|
86
88
|
// The database rules (R213-R216) read SQL through a WASM parser, so they are async: `starci app check` calls them beside the other emitters and passes the findings in as `extraFindings`.
|
|
87
89
|
export { checkDatabase } from './rules/database.mjs';
|
|
88
90
|
|
|
@@ -103,7 +105,7 @@ export const CHECK_CODES = Object.freeze([
|
|
|
103
105
|
'HFS_MANAGED_FILE_DRIFT', 'HFS_TOOL_CONFIG_LOCAL', 'HFS_RULE_OFF_WITHOUT_REPLACEMENT', 'HFS_TS_STRICT',
|
|
104
106
|
'HFS_PLAINTEXT_SECRET', 'HFS_STACKS_SHAPE', 'HFS_CI_MISSING_CANON', 'HFS_DEP_VERSION_SKEW', 'HFS_CONTRACT_SNAPSHOT_DRIFT',
|
|
105
107
|
'BE_TEST_TOPOLOGY', 'BE_SPEC_PLACEMENT', 'HFS_REPO_LOCAL_CHECK', 'HFS_LINT_SUPPRESSION_FILE', 'HFS_PROOF_COMMAND_FILE_MISSING', 'HFS_PEER_INTEGRATION_MISSING', 'BE_INTEGRATION_SPEC_MISSING', 'FE_GRAPHQL_CONTRACT', 'FE_NO_TESTS', 'HFS_MONO_WORKSPACES', 'HFS_MONO_FE_WORKSPACE', 'HFS_MONO_NEST_PROJECTS', 'HFS_MONO_WORKSPACE_DEP', 'BE_CLI_REQUIRED', 'FE_WIRE_GENERATED', 'FE_I18N_PLACEMENT', 'FE_I18N_CATALOG', 'HFS_SERVICE_PLACEMENT', 'HFS_IMAGE_UNPINNED', 'HFS_SERVICE_STACK_DECLARATION', 'HFS_EVENT_CONTRACT', 'BE_ASYNC_SPEC_MISSING', 'BE_SAGA_STEP_COMPENSATION', 'BE_SAGA_STATE_VERSIONED', 'BE_SAGA_EVENT_CONTRACT', 'BE_SAGA_CONSUMER_DEDUPE', 'BE_EVENT_CLASS_CONTRACT', 'BE_PATTERN_SPEC_MISSING', 'BE_CONTRACT_BREAKING', 'BE_KIND_DECLARATION', 'BE_KIND_EMPTY', 'HFS_DOCKER_BUILD_CONTEXT', 'HFS_DOCKER_STAGES', 'HFS_DOCKER_ENTRY', 'HFS_DOCKER_BASE_PIN', 'HFS_DOCKER_SECRETS', 'BE_SAGA_E2E_MISSING',
|
|
106
|
-
'FE_STACK_FORBIDDEN', 'HFS_EDITION_FORBIDDEN_PRESENT', 'HFS_LITE_SECRET_CUSTODY',
|
|
108
|
+
'FE_STACK_FORBIDDEN', 'HFS_EDITION_FORBIDDEN_PRESENT', 'HFS_LITE_SECRET_CUSTODY', 'HFS_STARCIWORK_LITE_FORBIDDEN',
|
|
107
109
|
'DB_MIGRATION_SHAPE', 'DB_RLS_REQUIRED', 'DB_DYNAMIC_DDL', 'DB_POLICY_SHAPE', 'DB_DEFINER_SAFE', 'DB_STORAGE_POLICY', 'DB_CONFIG_POLICY', 'DB_TYPES_DRIFT',
|
|
108
110
|
'HFS_GITIGNORE_BLOCK_DRIFT', 'HFS_SONAR_CONFIG', 'HFS_COVERAGE_SCOPE_DRIFT', 'HFS_FORMAT',
|
|
109
111
|
'HFS_EMPTY_DIR', 'HFS_GHOST_TREE', 'HFS_UNTRACKED_ROOT_ENTRY',
|
|
@@ -182,6 +184,7 @@ function summarize(findings) {
|
|
|
182
184
|
}
|
|
183
185
|
return {
|
|
184
186
|
error: findings.filter((f) => f.level === 'error').length,
|
|
187
|
+
warning: findings.filter((f) => f.level === 'warning').length,
|
|
185
188
|
info: findings.filter((f) => f.level === 'info').length,
|
|
186
189
|
byCode,
|
|
187
190
|
};
|
|
@@ -197,25 +200,6 @@ function judgedFindings(findings, edition, root) {
|
|
|
197
200
|
return findings.filter((finding) => catalog.judgedIn(finding.code, edition));
|
|
198
201
|
}
|
|
199
202
|
|
|
200
|
-
/** The tree findings of R03: empty directories, ghost siblings, untracked entries outside an ignored slot. */
|
|
201
|
-
function treeFindings({ repoRoot, resolver }) {
|
|
202
|
-
const inIgnoredSlot = (rel) => resolver.classifyPath(`${rel}/.probe`).tracking === 'ignored';
|
|
203
|
-
const findings = [];
|
|
204
|
-
const facts = treeFacts(readTree(repoRoot, { isIgnored: inIgnoredSlot }));
|
|
205
|
-
for (const { path: dir, below } of facts.empty) {
|
|
206
|
-
findings.push({ code: 'HFS_EMPTY_DIR', level: 'error', path: dir, below, message: `${dir} has no file below it` + (below ? ` (nor in its ${below} sub-director${below === 1 && 'y' || 'ies'})` : '') + '; git tracks no empty directory, so it is a leftover' });
|
|
207
|
-
}
|
|
208
|
-
for (const { path: dir, of, distance } of facts.ghosts) {
|
|
209
|
-
findings.push({ code: 'HFS_GHOST_TREE', level: 'error', path: dir, of, distance, message: `${dir} is empty and ${distance} edit${distance === 1 ? '' : 's'} from its sibling ${of}: a renamed or misspelt structure that was never removed` });
|
|
210
|
-
}
|
|
211
|
-
for (const entry of untrackedEntries(repoRoot)) {
|
|
212
|
-
const bare = entry.replace(/\/$/, '');
|
|
213
|
-
const ignored = entry.endsWith('/') ? inIgnoredSlot(bare) : resolver.classifyPath(bare).tracking === 'ignored';
|
|
214
|
-
if (!ignored) findings.push({ code: 'HFS_UNTRACKED_ROOT_ENTRY', level: 'error', path: bare, message: `${entry} is neither tracked nor git-ignored, and no ignored slot owns it` });
|
|
215
|
-
}
|
|
216
|
-
return findings;
|
|
217
|
-
}
|
|
218
|
-
|
|
219
203
|
/** A side-relative path (or null) as an app-relative one. */
|
|
220
204
|
const onSide = (side, p) => (p ? path.posix.normalize(`${side}/${p}`) : p);
|
|
221
205
|
|
|
@@ -223,7 +207,7 @@ function appendRequiredPathFindings(findings, resolver, files, required, present
|
|
|
223
207
|
const missing = new Set();
|
|
224
208
|
const missingFile = (slot, p, via) => {
|
|
225
209
|
const key = `${slot}|${p}`;
|
|
226
|
-
if (missing.has(key) || present(p)) return;
|
|
210
|
+
if (missing.has(key) || present(p) || !requiredFileApplies(slot, p, files)) return;
|
|
227
211
|
missing.add(key);
|
|
228
212
|
const app = appOf(p);
|
|
229
213
|
const appNote = app ? ` (app ${app})` : '';
|
|
@@ -347,7 +331,9 @@ export function checkRepo({ repoRoot, root = skillRoot, declaration, files, only
|
|
|
347
331
|
findings.push(...keep(scopeFindings({ repoRoot, root, repo, resolver, files: tracked, scoped, manifest }), repoRoot));
|
|
348
332
|
}
|
|
349
333
|
findings.push(...extraFindings);
|
|
350
|
-
|
|
334
|
+
const untrackedSecrets = untrackedSecretFindings({ repoRoot, files: tracked, repo });
|
|
335
|
+
findings.push(...untrackedSecrets);
|
|
336
|
+
if (tree) findings.push(...treeFindings({ repoRoot, resolver, tracked, reportedUntracked: new Set(untrackedSecrets.map((finding) => finding.path)) }));
|
|
351
337
|
|
|
352
338
|
// The app-root tree check reports machine codes: their why is read with the check's own.
|
|
353
339
|
const judged = judgedFindings(findings, repo.edition ?? 'full', root);
|
|
@@ -461,6 +447,12 @@ export function explainPath({ repoRoot, input, root = skillRoot, declaration, ma
|
|
|
461
447
|
const resolver = createSlotResolver(manifest, repo);
|
|
462
448
|
const why = readWhy(root);
|
|
463
449
|
const c = resolver.classifyPath(input);
|
|
450
|
+
const dir = /[\\/]$/.test(input) || (repoRoot && fs.existsSync(path.join(repoRoot, c.path)) && fs.statSync(path.join(repoRoot, c.path)).isDirectory());
|
|
451
|
+
const liteWork = liteStarciworkFinding(c.path, resolver, { dir });
|
|
452
|
+
if (liteWork) {
|
|
453
|
+
const entry = why[liteWork.code];
|
|
454
|
+
return { ...c, ...liteWork, status: 'forbidden', titleVi: entry.titleVi, whyVi: entry.whyVi };
|
|
455
|
+
}
|
|
464
456
|
if (c.status === 'no-slot') {
|
|
465
457
|
return { path: c.path, status: 'no-slot', code: 'HFS_SLOT_UNDECLARED', nearest: c.nearest, titleVi: why.HFS_SLOT_UNDECLARED.titleVi, whyVi: why.HFS_SLOT_UNDECLARED.whyVi };
|
|
466
458
|
}
|
|
@@ -323,6 +323,12 @@ function runtimeParamProblems(rp) {
|
|
|
323
323
|
];
|
|
324
324
|
}
|
|
325
325
|
|
|
326
|
+
const folderKindsValid = list => strList(list) && list.length > 0 && new Set(list).size === list.length && list.every(kind => NAME.test(kind));
|
|
327
|
+
const kindPairValid = pair => isPlainObject(pair) && Object.keys(pair).sort(byCodeUnit).join() === 'from,to'
|
|
328
|
+
&& ['from', 'to'].every(side => folderKindsValid(pair[side]));
|
|
329
|
+
const kindRestrictionValid = kinds => isPlainObject(kinds) && Object.keys(kinds).length > 0
|
|
330
|
+
&& Object.entries(kinds).every(([target, pair]) => NAME.test(target) && kindPairValid(pair));
|
|
331
|
+
|
|
326
332
|
/** Shape problems of the tier map of one profile. */
|
|
327
333
|
export function tierMapProblems(profile, tiers) {
|
|
328
334
|
const bad = [];
|
|
@@ -330,14 +336,33 @@ export function tierMapProblems(profile, tiers) {
|
|
|
330
336
|
for (const [tier, def] of Object.entries(tiers)) {
|
|
331
337
|
if (!NAME.test(tier)) bad.push(`tiers.${profile}.${tier} is not a tier name`);
|
|
332
338
|
if (!isPlainObject(def) || !Array.isArray(def.mayImport) || !def.mayImport.every((t) => NAME.test(String(t)))) bad.push(`tiers.${profile}.${tier}.mayImport must be a list of tier names`);
|
|
333
|
-
|
|
339
|
+
if (def?.mayImportKinds !== undefined && !kindRestrictionValid(def.mayImportKinds)) bad.push(`tiers.${profile}.${tier}.mayImportKinds must map tier names to {from, to} non-empty lists of unique folder kinds`);
|
|
340
|
+
for (const key of Object.keys(def ?? {})) if (!['mayImport', 'mayImportKinds', 'acyclic', 'lowerLayerOnly'].includes(key)) bad.push(`tiers.${profile}.${tier}.${key} is not a tier field`);
|
|
341
|
+
}
|
|
342
|
+
return bad;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function tierFolderKindProblems(manifest, profile, tier, kinds, at) {
|
|
346
|
+
const known = new Set(manifest.slots.filter((slot) => slot.profiles.includes(profile) && slot.tier === tier).flatMap((slot) => [...(slot.kinds ?? []), ...(slot.layers ?? [])]));
|
|
347
|
+
return kinds.filter(kind => !known.has(kind)).map(kind => `${at} names ${kind}, which is not a folder kind of the ${tier} tier`);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/** Kind restrictions narrow an allowed tier edge and name kinds that its source and target slots actually declare. */
|
|
351
|
+
export function tierKindProblems(manifest, profile) {
|
|
352
|
+
const bad = [];
|
|
353
|
+
for (const [tier, def] of Object.entries(manifest.tiers[profile])) {
|
|
354
|
+
for (const [target, kinds] of Object.entries(def.mayImportKinds ?? {})) {
|
|
355
|
+
const at = `tiers.${profile}.${tier}.mayImportKinds.${target}`;
|
|
356
|
+
if (!def.mayImport.includes(target)) bad.push(`${at} restricts a tier that mayImport does not allow`);
|
|
357
|
+
bad.push(...tierFolderKindProblems(manifest, profile, tier, kinds.from, `${at}.from`), ...tierFolderKindProblems(manifest, profile, target, kinds.to, `${at}.to`));
|
|
358
|
+
}
|
|
334
359
|
}
|
|
335
360
|
return bad;
|
|
336
361
|
}
|
|
337
362
|
|
|
338
363
|
/** The semantic rules of a runtime manifest beyond the slot rules both kinds share: tier names and the schema major. */
|
|
339
364
|
export function runtimeSemanticProblems(m) {
|
|
340
|
-
const bad =
|
|
365
|
+
const bad = tierKindProblems(m, RUNTIME_KIND);
|
|
341
366
|
const tiers = m.tiers.runtime;
|
|
342
367
|
for (const [tier, def] of Object.entries(tiers)) {
|
|
343
368
|
for (const target of def.mayImport) if (!(target in tiers)) bad.push(`tiers.runtime.${tier}.mayImport names ${target}, which is not a runtime tier`);
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// (scripts/hfs/project-graph.mjs) runs it over the same tree and serves the findings on a TypeScript file as ESLint reports.
|
|
4
4
|
// HFS_SLOT_UNDECLARED / HFS_SLOT_AMBIGUOUS / HFS_SLOT_NOT_ENABLED / HFS_FORBIDDEN_PRESENT / HFS_TOOL_CONFIG_LOCAL / HFS_TRACKED_MUST_BE_IGNORED (R01, R16)
|
|
5
5
|
// BE_SOURCE_FORM (R89, the file name), BE_SPEC_PLACEMENT (R102)
|
|
6
|
+
import fs from 'node:fs';
|
|
6
7
|
import path from 'node:path';
|
|
7
8
|
import { allowsFile } from './allows.mjs';
|
|
8
9
|
import { isFeTestPath } from './rules/fe-no-tests.mjs';
|
|
@@ -30,8 +31,11 @@ function sourceFormContext(file, resolver, suffixes, bannedSuffixes) {
|
|
|
30
31
|
const slot = resolver.slot(c.slot);
|
|
31
32
|
if ([...(slot?.requires ?? []), ...(slot?.allows ?? [])].includes(base)) return null;
|
|
32
33
|
const admitted = allowsFile(resolver, file);
|
|
34
|
+
// Environment parsing is an app slot role, not a source suffix admitted outside that matching app file.
|
|
35
|
+
if (c.slot.startsWith('be.app.') && admitted?.allowed && admitted.entry === '<app>.env.ts') return null;
|
|
33
36
|
if (admitted?.allowed && admitted.entry?.includes('/') && path.posix.basename(admitted.entry) === base) return null;
|
|
34
|
-
|
|
37
|
+
// A plain entry names its file by a variable only (`<x>.ts`, or `compose/<area>.ts` of an app slot): a kebab name is its form.
|
|
38
|
+
if (admitted?.allowed && PLAIN_ENTRY.test(path.posix.basename(admitted.entry ?? '')) && KEBAB.test(base.slice(0, -'.ts'.length))) return null;
|
|
35
39
|
if (c.slot === 'be.persistence' && path.posix.basename(path.posix.dirname(file)) === 'migrations') return null;
|
|
36
40
|
return { c, base, suffixes, bannedSuffixes };
|
|
37
41
|
}
|
|
@@ -54,7 +58,7 @@ function sourceFormFinding(file, { c, base, suffixes, bannedSuffixes }, boundSuf
|
|
|
54
58
|
|
|
55
59
|
/**
|
|
56
60
|
* BE_SOURCE_FORM (R89): every tracked src/ or apps/ TypeScript file of a back end is index.ts, main.ts, a migration of
|
|
57
|
-
* be.persistence, or <kebab-name>.<suffix>.ts with <suffix> in the closed vocabulary ruleParams.be.suffixes (a name such
|
|
61
|
+
* be.persistence, the matching <app>.env.ts explicitly admitted by its app slot, or <kebab-name>.<suffix>.ts with <suffix> in the closed vocabulary ruleParams.be.suffixes (a name such
|
|
58
62
|
* as order.service.spec.ts keeps its inner words kebab-case). A suffix of ruleParams.be.bannedSuffixes anywhere in the
|
|
59
63
|
* name is refused by name. Paths no slot owns are HFS_SLOT_UNDECLARED's, not this code's.
|
|
60
64
|
*/
|
|
@@ -86,6 +90,8 @@ function forbiddenPathFinding(file, c, resolver) {
|
|
|
86
90
|
}
|
|
87
91
|
|
|
88
92
|
function pathStatusFinding(file, resolver) {
|
|
93
|
+
const liteWork = liteStarciworkFinding(file, resolver);
|
|
94
|
+
if (liteWork) return liteWork;
|
|
89
95
|
const c = resolver.classifyPath(file);
|
|
90
96
|
if (c.status === 'no-slot') {
|
|
91
97
|
const nearestNote = c.nearest ? `; nearest slot ${c.nearest.slot} (${c.nearest.pattern}), matched ${c.nearest.matchedPrefix || '.'} then expected ${c.nearest.expectedNext ?? 'nothing'}` : '';
|
|
@@ -98,6 +104,30 @@ function pathStatusFinding(file, resolver) {
|
|
|
98
104
|
return null;
|
|
99
105
|
}
|
|
100
106
|
|
|
107
|
+
/** The lite Work boundary, read from app.starciwork's edition-specific admission list, including paths a narrower slot owns. */
|
|
108
|
+
export function liteStarciworkFinding(file, resolver, { dir = false } = {}) {
|
|
109
|
+
if (resolver.repo?.edition !== 'lite' || !resolver.slot('app.starciwork')) return null;
|
|
110
|
+
const p = resolver.classifyPath(file).path;
|
|
111
|
+
if (!p.startsWith('.starciwork/')) return null;
|
|
112
|
+
const admitted = allowsFile(resolver, dir ? `${p}/.probe` : p);
|
|
113
|
+
if (admitted?.slot === 'app.starciwork' && admitted.allowed) return null;
|
|
114
|
+
return { code: 'HFS_STARCIWORK_LITE_FORBIDDEN', level: 'error', path: p, slot: 'app.starciwork', message: `${p}: lite .starciwork holds only workspace.yaml and brand/ (plus .gitignore); remove this path or use the full edition` };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Local lite Work leftovers, including metadata ignored by an old full-edition .gitignore. */
|
|
118
|
+
export function liteStarciworkTreeFindings(repoRoot, resolver, tracked) {
|
|
119
|
+
if (resolver.repo?.edition !== 'lite' || !resolver.slot('app.starciwork')) return [];
|
|
120
|
+
let entries = [];
|
|
121
|
+
try { entries = fs.readdirSync(path.join(repoRoot, '.starciwork'), { withFileTypes: true }); } catch { /* Work is optional. */ }
|
|
122
|
+
const trackedSet = new Set(tracked);
|
|
123
|
+
return entries.flatMap(entry => {
|
|
124
|
+
const file = `.starciwork/${entry.name}`;
|
|
125
|
+
if (trackedSet.has(file)) return [];
|
|
126
|
+
const finding = liteStarciworkFinding(file, resolver, { dir: entry.isDirectory() });
|
|
127
|
+
return finding ? [finding] : [];
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
101
131
|
/** The findings of the slot manifest over `files` (repository-relative tracked paths) of a repository of `profile`. */
|
|
102
132
|
export function pathFindings({ files, resolver, profile }) {
|
|
103
133
|
const findings = [];
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
// database-migrations.mjs - L02 migration name, UTC ordering and base-branch immutability checks. Git reads go
|
|
2
2
|
// through the runtime API call files, and specs may inject the same small runner contract used by database.mjs.
|
|
3
3
|
import { lsTree } from '../../api/git/ls-tree.mjs';
|
|
4
|
-
import { log } from '../../api/git/log.mjs';
|
|
5
4
|
import { mergeBase } from '../../api/git/merge-base.mjs';
|
|
6
5
|
import { show } from '../../api/git/show.mjs';
|
|
7
6
|
import { withoutGitLocalEnv } from '../../lib/git.mjs';
|
|
@@ -27,7 +26,6 @@ export function defaultGit(args, { cwd }) {
|
|
|
27
26
|
if (verb === 'merge-base') { const sha = mergeBase(cwd, rest[0], rest[1]); return { ok: sha !== null, stdout: sha ?? '' }; }
|
|
28
27
|
let call = show;
|
|
29
28
|
if (verb === 'ls-tree') call = lsTree;
|
|
30
|
-
else if (verb === 'log') call = log;
|
|
31
29
|
const result = call(rest, { cwd, env, maxBuffer: 64 * 1024 * 1024 });
|
|
32
30
|
return { ok: !result.error && result.status === 0, stdout: result.stdout };
|
|
33
31
|
}
|
|
@@ -45,11 +43,11 @@ async function baseShaOf(git, repoRoot, base) {
|
|
|
45
43
|
return found;
|
|
46
44
|
}
|
|
47
45
|
|
|
48
|
-
async function
|
|
46
|
+
async function migrationFilesAt(git, repoRoot, sha) {
|
|
49
47
|
const result = await runGit(git, repoRoot, ['ls-tree', '-r', '--name-only', '-z', sha, '--', MIGRATIONS_DIR]);
|
|
50
48
|
if (!result.ok) return [];
|
|
51
49
|
const prefix = `${MIGRATIONS_DIR}/`;
|
|
52
|
-
return result.stdout.split('\0').filter(
|
|
50
|
+
return result.stdout.split('\0').filter((entry) => entry.startsWith(prefix));
|
|
53
51
|
}
|
|
54
52
|
|
|
55
53
|
async function baseFileText(git, repoRoot, sha, file) {
|
|
@@ -58,46 +56,48 @@ async function baseFileText(git, repoRoot, sha, file) {
|
|
|
58
56
|
return result.ok ? result.stdout : null;
|
|
59
57
|
}
|
|
60
58
|
|
|
61
|
-
|
|
62
|
-
const result = await runGit(git, repoRoot, ['log', '-1', '--format=%ct', '--', file]);
|
|
63
|
-
const seconds = Number(result.stdout.trim());
|
|
64
|
-
if (result.ok && Number.isFinite(seconds) && seconds > 0) return { at: seconds * 1000, tracked: true };
|
|
65
|
-
return { at: now(), tracked: false };
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
async function localMigrationFindings(local, repoRoot, git, now) {
|
|
59
|
+
function localMigrationFindings(local) {
|
|
69
60
|
const findings = [];
|
|
70
61
|
const stamps = new Map();
|
|
71
|
-
|
|
62
|
+
for (const entry of local) {
|
|
72
63
|
if (!entry.match) {
|
|
73
64
|
findings.push(found(DB_MIGRATION_SHAPE, entry.file, `${entry.file} is not named <ts14>_<kebab>.sql; a migration is created by \`npm run db:new\` (supabase migration new), never renamed by hand`, { expected: '<ts14>_<kebab>.sql' }));
|
|
74
|
-
|
|
65
|
+
continue;
|
|
75
66
|
}
|
|
76
67
|
const stamp = migrationStamp(entry.match[1]);
|
|
77
68
|
if (stamp === null) {
|
|
78
|
-
findings.push(found(DB_MIGRATION_SHAPE, entry.file, `${entry.file} carries ${entry.match[1]}, which is no valid UTC time;
|
|
79
|
-
|
|
80
|
-
}
|
|
81
|
-
const limit = await migrationTimeLimit(git, repoRoot, entry.file, now);
|
|
82
|
-
if (stamp > limit.at) {
|
|
83
|
-
const boundary = limit.tracked ? `the file's commit time ${new Date(limit.at).toISOString()}` : `the current time ${new Date(limit.at).toISOString()}`;
|
|
84
|
-
findings.push(found(DB_MIGRATION_SHAPE, entry.file, `${entry.file} is stamped ${entry.match[1]}, in the future relative to ${boundary}; a migration stamp is its real creation time, never a hand-picked date`, { stamp: entry.match[1], tracked: limit.tracked }));
|
|
69
|
+
findings.push(found(DB_MIGRATION_SHAPE, entry.file, `${entry.file} carries ${entry.match[1]}, which is no valid UTC time; a migration stamp must name a valid UTC instant`, { stamp: entry.match[1] }));
|
|
70
|
+
continue;
|
|
85
71
|
}
|
|
86
72
|
if (stamps.has(stamp)) findings.push(found(DB_MIGRATION_SHAPE, entry.file, `${entry.file} shares stamp ${entry.match[1]} with ${stamps.get(stamp)}; stamps are strictly increasing`, { stamp: entry.match[1], other: stamps.get(stamp) }));
|
|
87
73
|
stamps.set(stamp, entry.file);
|
|
88
|
-
}
|
|
74
|
+
}
|
|
89
75
|
return findings;
|
|
90
76
|
}
|
|
91
77
|
|
|
92
|
-
|
|
78
|
+
/** HEAD owns the append boundary. Archived names retire the old chain, so a replacement baseline starts afresh. */
|
|
79
|
+
function migrationHistory(files, retired, localNames) {
|
|
80
|
+
const prefix = `${MIGRATIONS_DIR}/`;
|
|
81
|
+
const onHead = new Set(files.filter(isActiveMigration).map((file) => file.slice(prefix.length)));
|
|
82
|
+
const retiredNames = new Set([...retired, ...files.filter((file) => file.startsWith(`${MIGRATIONS_ARCHIVE_DIR}/`)).map((file) => file.slice(MIGRATIONS_ARCHIVE_DIR.length + 1))].filter((name) => !localNames.has(name)));
|
|
83
|
+
const stamps = [...onHead].filter((name) => !retiredNames.has(name)).map((name) => MIGRATION_NAME.exec(name)?.[1]).filter(Boolean).map(migrationStamp).filter((stamp) => stamp !== null);
|
|
84
|
+
return { onHead, latest: stamps.length ? Math.max(...stamps) : null };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function migrationOrderingFindings(local, history) {
|
|
88
|
+
return local.flatMap((entry) => {
|
|
89
|
+
const stamp = entry.match ? migrationStamp(entry.match[1]) : null;
|
|
90
|
+
if (history.onHead.has(entry.name) || stamp === null || history.latest === null || stamp > history.latest) return [];
|
|
91
|
+
return [found(DB_MIGRATION_SHAPE, entry.file, `${entry.file} is stamped ${entry.match[1]}, not after every migration already in repository history (latest active ${new Date(history.latest).toISOString()}); a new migration sorts after the committed active chain`, { stamp: entry.match[1], latest: new Date(history.latest).toISOString() })];
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function baseMigrationEntryFindings(entry, repoRoot, git, baseSha, onBase) {
|
|
93
96
|
if (!entry.match) return [];
|
|
94
|
-
const stamp = migrationStamp(entry.match[1]);
|
|
95
97
|
if (onBase.has(entry.name)) {
|
|
96
98
|
const committed = await baseFileText(git, repoRoot, baseSha.sha, entry.file);
|
|
97
99
|
const current = readText(repoRoot, entry.file);
|
|
98
100
|
if (!sameText(current, committed)) return [found(DB_MIGRATION_SHAPE, entry.file, `${entry.file} differs from its content on ${baseSha.ref}; a migration on the base branch is immutable - write a new migration`, { base: baseSha.ref })];
|
|
99
|
-
} else if (baseMax !== null && stamp !== null && stamp <= baseMax) {
|
|
100
|
-
return [found(DB_MIGRATION_SHAPE, entry.file, `${entry.file} is stamped ${entry.match[1]}, not after every migration on ${baseSha.ref} (latest ${String(new Date(baseMax).toISOString())}); a new migration sorts after the base ones`, { stamp: entry.match[1], base: baseSha.ref })];
|
|
101
101
|
}
|
|
102
102
|
return [];
|
|
103
103
|
}
|
|
@@ -112,26 +112,27 @@ function removedMigrationFindings(local, retired, baseNames, baseSha) {
|
|
|
112
112
|
|
|
113
113
|
/**
|
|
114
114
|
* L02 findings for names, stamps, ordering and immutability of supabase/migrations/*.sql. `archived` are the files of
|
|
115
|
-
* supabase/migrations/_archive/ (app.supabase.migrations.archive): never judged, and a
|
|
116
|
-
* cutover is retired, not removed.
|
|
115
|
+
* supabase/migrations/_archive/ (app.supabase.migrations.archive): never judged, and a migration moved there at a baseline
|
|
116
|
+
* cutover is retired from the ordering chain, not removed. New files append after HEAD's active migrations; timestamps
|
|
117
|
+
* on archived migrations, remote branches and the wall clock do not constrain a replacement baseline.
|
|
117
118
|
*/
|
|
118
|
-
export async function migrationShapeFindings({ repoRoot, migrations, archived = [], git, base
|
|
119
|
+
export async function migrationShapeFindings({ repoRoot, migrations, archived = [], git, base }) {
|
|
119
120
|
const prefix = `${MIGRATIONS_DIR}/`;
|
|
120
121
|
const local = migrations.map((file) => {
|
|
121
122
|
const name = file.startsWith(prefix) ? file.slice(prefix.length) : file;
|
|
122
123
|
return { file, name, match: MIGRATION_NAME.exec(name) };
|
|
123
124
|
});
|
|
124
|
-
const findings =
|
|
125
|
+
const findings = localMigrationFindings(local);
|
|
126
|
+
const retired = new Set(archived.map((file) => file.slice(MIGRATIONS_ARCHIVE_DIR.length + 1)));
|
|
127
|
+
const history = migrationHistory(await migrationFilesAt(git, repoRoot, 'HEAD'), retired, new Set(local.map((entry) => entry.name)));
|
|
128
|
+
findings.push(...migrationOrderingFindings(local, history));
|
|
125
129
|
const baseSha = await baseShaOf(git, repoRoot, base);
|
|
126
130
|
if (!baseSha) return findings;
|
|
127
|
-
const baseNames = await
|
|
131
|
+
const baseNames = (await migrationFilesAt(git, repoRoot, baseSha.sha)).filter(isActiveMigration).map((file) => file.slice(prefix.length));
|
|
128
132
|
const onBase = new Set(baseNames);
|
|
129
|
-
const baseStamps = baseNames.map((name) => MIGRATION_NAME.exec(name)?.[1]).filter(Boolean).map(migrationStamp).filter((stamp) => stamp !== null);
|
|
130
|
-
const baseMax = baseStamps.length ? Math.max(...baseStamps) : null;
|
|
131
133
|
await eachInOrder(local, async (entry) => {
|
|
132
|
-
findings.push(...await baseMigrationEntryFindings(entry, repoRoot, git, baseSha, onBase
|
|
134
|
+
findings.push(...await baseMigrationEntryFindings(entry, repoRoot, git, baseSha, onBase));
|
|
133
135
|
});
|
|
134
|
-
const retired = new Set(archived.map((file) => file.slice(MIGRATIONS_ARCHIVE_DIR.length + 1)));
|
|
135
136
|
findings.push(...removedMigrationFindings(local, retired, baseNames, baseSha));
|
|
136
137
|
return findings;
|
|
137
138
|
}
|
|
@@ -51,7 +51,7 @@ async function migrationAnalyses({ repoRoot, migrations, exposed, forceRls, find
|
|
|
51
51
|
* starts neither Docker nor the Supabase CLI. A full app is judged when it declares a Supabase connection, while
|
|
52
52
|
* lite apps are judged even before their initial Supabase files have all been scaffolded.
|
|
53
53
|
*/
|
|
54
|
-
export async function checkDatabase({ repoRoot, files, base, git, edition, supabase, emitTypes
|
|
54
|
+
export async function checkDatabase({ repoRoot, files, base, git, edition, supabase, emitTypes } = {}) {
|
|
55
55
|
const findings = [];
|
|
56
56
|
const migrations = files.filter(isActiveMigration).sort(byCodeUnit);
|
|
57
57
|
const archived = files.filter((file) => file.startsWith(`${MIGRATIONS_ARCHIVE_DIR}/`));
|
|
@@ -71,7 +71,7 @@ export async function checkDatabase({ repoRoot, files, base, git, edition, supab
|
|
|
71
71
|
const apiSchemas = (config?.api?.schemas ?? []).filter((schema) => typeof schema === 'string');
|
|
72
72
|
const exposed = new Set(['public', ...apiSchemas]);
|
|
73
73
|
const forceRls = new Set((declared?.forceRls ?? []).map((name) => (name.includes('.') ? name : `public.${name}`)));
|
|
74
|
-
findings.push(...await migrationShapeFindings({ repoRoot, migrations, archived, git: git ?? defaultGit, base
|
|
74
|
+
findings.push(...await migrationShapeFindings({ repoRoot, migrations, archived, git: git ?? defaultGit, base }));
|
|
75
75
|
|
|
76
76
|
const analyses = await migrationAnalyses({ repoRoot, migrations, exposed, forceRls, findings });
|
|
77
77
|
findings.push(
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// edition.mjs - L01, the edition rule. An hfs.json edition is `full` or `lite` (absent means full); under lite the standard is a
|
|
2
|
-
// filter plus a few additions over the same manifest, never a second engine. Lite
|
|
3
|
-
//
|
|
2
|
+
// filter plus a few additions over the same manifest, never a second engine. Lite keeps the full code structure and swaps only
|
|
3
|
+
// backing infrastructure for Supabase (owner ruling 2026-10-13): the api and sidecars run on Swarm, with the same slots, patterns and unit specs behind the same
|
|
4
4
|
// ports, and no e2e or contract world. The slot layer already answers the paths lite removed (slots lite excludes, or whose
|
|
5
5
|
// litePresence is forbidden, come back HFS_FORBIDDEN_PRESENT with their goesTo); this rule reports what no path can state:
|
|
6
6
|
// - inside a package.json: an e2e, contract, stack or browser script (`test:e2e`, `test:contract`, `test:stack`, `test:browser`,
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
// while full owns them (BullMQ, Kafka, Redis: lite runs pgmq and Postgres locks instead);
|
|
10
10
|
// - a declaration of what lite does not have: an app kind, pattern or trigger kind every slot of which is forbidden under
|
|
11
11
|
// lite. The forbidden names are DERIVED from the manifest, so adding or restoring a kind is a manifest edit, not an edit here
|
|
12
|
-
// (under the 2026-10-
|
|
12
|
+
// (under the 2026-10-13 ruling no pattern, trigger or app kind is gone).
|
|
13
13
|
// A full app answers []: the rule tests the edition once and a full view forbids none of these names.
|
|
14
14
|
import { found, readJson } from './read.mjs';
|
|
15
15
|
import { DEPENDENCY_SECTIONS } from './fe-no-tests.mjs';
|
|
@@ -18,7 +18,7 @@ import { editionRuleParams } from '../edition-slots.mjs';
|
|
|
18
18
|
export const HFS_EDITION_FORBIDDEN_PRESENT = 'HFS_EDITION_FORBIDDEN_PRESENT';
|
|
19
19
|
|
|
20
20
|
const PACKAGE_JSON = /(?:^|\/)package\.json$/;
|
|
21
|
-
/** The scripts of a world lite does not have:
|
|
21
|
+
/** The scripts of a test world lite does not have: e2e, contract, test-stack and browser journeys. Production Swarm deployment is legal. */
|
|
22
22
|
const LITE_FORBIDDEN_SCRIPT = /^(?:pre|post)?test:(?:e2e|contract|stack|browser)$/;
|
|
23
23
|
/** The dependencies of a test infrastructure lite does not run: containers, fault proxies and browser runners. */
|
|
24
24
|
const LITE_FORBIDDEN_TEST_INFRA = /^(?:testcontainers|@testcontainers\/.+|toxiproxy-node-client|playwright|playwright-core|@playwright\/.+|cypress)$/;
|
|
@@ -78,7 +78,7 @@ function dependencyFindings(rel, pkg, removed) {
|
|
|
78
78
|
if (!isPlainObject(deps)) continue;
|
|
79
79
|
for (const name of Object.keys(deps)) {
|
|
80
80
|
if (LITE_FORBIDDEN_TEST_INFRA.test(name)) findings.push(found(HFS_EDITION_FORBIDDEN_PRESENT, rel, `${rel} ${section} has ${name}: a lite app runs no test containers, fault proxy or browser journey - its integration specs run against \`supabase start\``, { section, name }));
|
|
81
|
-
else if (removed.has(name)) findings.push(found(HFS_EDITION_FORBIDDEN_PRESENT, rel, `${rel} ${section} has ${name}: lite
|
|
81
|
+
else if (removed.has(name)) findings.push(found(HFS_EDITION_FORBIDDEN_PRESENT, rel, `${rel} ${section} has ${name}: lite replaces backing infrastructure with Supabase - the pattern's port is implemented on Supabase (pgmq, Postgres locks)`, { section, name }));
|
|
82
82
|
}
|
|
83
83
|
}
|
|
84
84
|
return findings;
|
|
@@ -12,6 +12,7 @@ import fs from 'node:fs';
|
|
|
12
12
|
import path from 'node:path';
|
|
13
13
|
import { parseYaml } from '../../../engine/yaml.mjs';
|
|
14
14
|
import { found, readText } from './read.mjs';
|
|
15
|
+
import { liteStarciworkFinding } from '../path-findings.mjs';
|
|
15
16
|
|
|
16
17
|
const PROOF_COMMAND_FILE_MISSING = 'HFS_PROOF_COMMAND_FILE_MISSING';
|
|
17
18
|
const RECORD = /^\.starciwork\/.+\.ya?ml$/;
|
|
@@ -62,6 +63,7 @@ export function proofCommandFindings({ repoRoot, files, resolver, sides }) {
|
|
|
62
63
|
const own = new Set(sides);
|
|
63
64
|
for (const file of files) {
|
|
64
65
|
if (!RECORD.test(file)) continue;
|
|
66
|
+
if (liteStarciworkFinding(file, resolver)) continue;
|
|
65
67
|
const text = readText(repoRoot, file);
|
|
66
68
|
if (text === null || !text.includes('requiresProof')) continue;
|
|
67
69
|
let record;
|
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
// - a tracked `.enc` that is not a sops envelope (an encrypted-looking file that holds plaintext);
|
|
6
6
|
// - a line of any other tracked text file that matches a secret pattern.
|
|
7
7
|
// A finding names the file, the line and the pattern, never the value.
|
|
8
|
+
import fs from 'node:fs';
|
|
9
|
+
import path from 'node:path';
|
|
8
10
|
import { FORBIDDEN_FILES, secretHits } from '../../lib/secret-patterns.mjs';
|
|
9
11
|
import { isSopsEnvelope } from '../../lib/sops-envelope.mjs';
|
|
10
12
|
import { found, readText } from './read.mjs';
|
|
@@ -16,6 +18,17 @@ const NOT_SCANNED = /(^|\/)package-lock\.json$/;
|
|
|
16
18
|
/** True when the slot `id` of the manifest reports HFS_PLAINTEXT_SECRET for the paths it forbids. */
|
|
17
19
|
export const slotOwnsSecrets = (slot) => slot?.rules?.includes(PLAINTEXT_SECRET) === true;
|
|
18
20
|
|
|
21
|
+
/** A legacy lite root secrets.env is reported even when ignored; its presence is enough, so no value is read. */
|
|
22
|
+
export function untrackedSecretFindings({ repoRoot, files, repo }) {
|
|
23
|
+
if (repo.profile !== 'app' || repo.edition !== 'lite' || files.includes('secrets.env')) return [];
|
|
24
|
+
try {
|
|
25
|
+
if (!fs.statSync(path.join(repoRoot, 'secrets.env')).isFile()) return [];
|
|
26
|
+
} catch {
|
|
27
|
+
return [];
|
|
28
|
+
}
|
|
29
|
+
return [found(PLAINTEXT_SECRET, 'secrets.env', 'secrets.env is an untracked plaintext secret at the lite app root; seal it with starci app secret set into .starcistacks/<env>/secrets/<slug>.enc and remove the plaintext file; read materialized values by *_FILE', { level: 'warning', pattern: 'untracked-secrets-env' })];
|
|
30
|
+
}
|
|
31
|
+
|
|
19
32
|
/**
|
|
20
33
|
* The findings of R06 for one tracked file that no forbidden slot claims: a secret by being (an env file, a key file), an `.enc` that is
|
|
21
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
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// R10 runtime consistency: app-owned scheduling, managed lite backing and Swarm deployment safety.
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { isPlainObject as plain } from '../../../engine/plain-object.mjs';
|
|
5
|
+
import { readDeclaration } from '../../lib/stack-declaration.mjs';
|
|
6
|
+
import { isInside } from '../../lib/walk.mjs';
|
|
7
|
+
import { composeIncludePaths, swarmInventoryIssues, swarmModelIssues } from '../../lib/swarm-stack.mjs';
|
|
8
|
+
import { found, readText } from './read.mjs';
|
|
9
|
+
|
|
10
|
+
const CODE = 'HFS_STACKS_SHAPE';
|
|
11
|
+
const DECLARATION = '.starcistacks/application-stacks.yaml';
|
|
12
|
+
const HOST_CRON_PATH = /^(?:\.github\/workflows|deploy|scripts|\.starcistacks)\//;
|
|
13
|
+
const HOST_CRON = /\bcrontab\s+(?:-l\b|-(?=\s|[;|&)]|$))|(?:\/etc\/)?\bcron\.d(?:\/|\b)|@reboot\b/i;
|
|
14
|
+
const mapping = value => plain(value) ? value : {};
|
|
15
|
+
const issueFinding = (file, issue) => found(CODE, file, `${file}: ${issue.at}: ${issue.message}`);
|
|
16
|
+
|
|
17
|
+
/** The app's tracked automation sources; locations are reported without returning any source text. */
|
|
18
|
+
export function hostCrontabFindings({ repoRoot, files }) {
|
|
19
|
+
const findings = [];
|
|
20
|
+
for (const file of files.filter(file => HOST_CRON_PATH.test(file) && !file.endsWith('.enc'))) {
|
|
21
|
+
const text = readText(repoRoot, file);
|
|
22
|
+
if (text === null) continue;
|
|
23
|
+
for (const [index, line] of text.split(/\r?\n/).entries()) {
|
|
24
|
+
if (HOST_CRON.test(line)) findings.push(found(CODE, file,
|
|
25
|
+
`${file}:${index + 1}: host crontab installation is forbidden; recurring work must use an app scheduler, pgmq/pg_cron, or a declared Swarm scheduler and replicated-job`, { line: index + 1 }));
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return findings;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function safeStackFile(repoRoot, envDir, relative) {
|
|
32
|
+
if (typeof relative !== 'string' || path.isAbsolute(relative) || relative.split(/[\\/]/).includes('..')) return null;
|
|
33
|
+
const target = path.resolve(envDir, relative);
|
|
34
|
+
const base = path.resolve(repoRoot);
|
|
35
|
+
if (!isInside(base, envDir) || !isInside(envDir, target)) return null;
|
|
36
|
+
let cursor = target;
|
|
37
|
+
while (true) {
|
|
38
|
+
if (fs.existsSync(cursor) && fs.lstatSync(cursor).isSymbolicLink()) return null;
|
|
39
|
+
if (cursor === base) break;
|
|
40
|
+
cursor = path.dirname(cursor);
|
|
41
|
+
}
|
|
42
|
+
return target;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function mergeMapping(current, incoming) {
|
|
46
|
+
const result = { ...mapping(current) };
|
|
47
|
+
for (const [key, value] of Object.entries(mapping(incoming))) {
|
|
48
|
+
result[key] = plain(value) ? mergeMapping(result[key], value) : value;
|
|
49
|
+
}
|
|
50
|
+
return result;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function includePaths(doc, relative, file, findings) {
|
|
54
|
+
const { paths, errors } = composeIncludePaths(doc);
|
|
55
|
+
for (const message of errors) findings.push(issueFinding(file, { at: 'include', message }));
|
|
56
|
+
return paths.map(value => path.isAbsolute(value) ? value : path.posix.join(path.posix.dirname(relative), value));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function loadSwarmModel({ repoRoot, environment, spec, files, findings }) {
|
|
60
|
+
const prefix = `.starcistacks/${environment}/`, envDir = path.resolve(repoRoot, prefix);
|
|
61
|
+
const queue = [...(Array.isArray(spec.composeFiles) ? spec.composeFiles : [])], seen = new Set();
|
|
62
|
+
let model = {};
|
|
63
|
+
if (!queue.length) findings.push(found(CODE, DECLARATION, `${DECLARATION}: environments.${environment}.composeFiles must declare the Swarm stack sources`));
|
|
64
|
+
while (queue.length) {
|
|
65
|
+
const relative = queue.shift();
|
|
66
|
+
if (seen.has(relative)) continue;
|
|
67
|
+
seen.add(relative);
|
|
68
|
+
const file = `${prefix}${relative}`, target = safeStackFile(repoRoot, envDir, relative);
|
|
69
|
+
const parsed = target && files.includes(file) ? readDeclaration(target) : { error: 'missing, untracked or unsafe stack source' };
|
|
70
|
+
if (parsed.error) {
|
|
71
|
+
findings.push(found(CODE, file, `${file}: Swarm stack cannot be read: ${parsed.error}`));
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
model = mergeMapping(model, parsed.doc);
|
|
75
|
+
queue.push(...includePaths(parsed.doc, relative, file, findings));
|
|
76
|
+
}
|
|
77
|
+
return { model, sources: new Set([...seen].map(relative => `${prefix}${relative}`)) };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function swarmDeploymentFiles(repoRoot, files) {
|
|
81
|
+
return files.filter(file => {
|
|
82
|
+
const environment = /^\.starcistacks\/([^/]+)\/infra\/swarm\//.exec(file)?.[1];
|
|
83
|
+
if (!environment || file.endsWith('.enc')) return false;
|
|
84
|
+
const target = safeStackFile(repoRoot, path.resolve(repoRoot, '.starcistacks', environment), file.split('/').slice(2).join('/'));
|
|
85
|
+
if (!target) return true;
|
|
86
|
+
const parsed = readDeclaration(target);
|
|
87
|
+
return path.basename(file) === 'stack.yaml' || plain(parsed.doc?.services) || Array.isArray(parsed.doc?.include);
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function liteEnvironmentFindings({ doc, environments, deployments }) {
|
|
92
|
+
const findings = [];
|
|
93
|
+
const delivery = deployments.length || Object.values(environments).some(spec => Array.isArray(spec?.composeFiles) && spec.composeFiles.length);
|
|
94
|
+
if (delivery && (doc.backing?.supabase?.provider !== 'supabase' || doc.backing?.supabase?.managed !== true))
|
|
95
|
+
findings.push(found(CODE, DECLARATION, `${DECLARATION}: lite Swarm delivery must declare backing.supabase with provider: supabase and managed: true`));
|
|
96
|
+
for (const [environment, spec] of Object.entries(environments)) {
|
|
97
|
+
if (!Array.isArray(spec?.composeFiles) || !spec.composeFiles.length) continue;
|
|
98
|
+
if (spec.runtime !== 'docker-swarm' || spec.composeFiles.some(file => typeof file !== 'string' || !file.startsWith('infra/swarm/')))
|
|
99
|
+
findings.push(found(CODE, DECLARATION, `${DECLARATION}: environments.${environment} lite delivery must use runtime: docker-swarm and infra/swarm sources`));
|
|
100
|
+
}
|
|
101
|
+
return findings;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function undeclaredSwarmFindings(deployments, environments, sources) {
|
|
105
|
+
return deployments.filter(file => !sources.has(file)).map(file => {
|
|
106
|
+
const environment = file.split('/')[1], spec = environments[environment];
|
|
107
|
+
const reason = spec?.runtime === 'docker-swarm' ? 'is not declared or reachable from composeFiles' : 'has no declared docker-swarm environment';
|
|
108
|
+
return found(CODE, file, `${file}: Swarm deployment ${reason}`);
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Declared Swarm sources are checked for both editions; lite additionally refuses local managed backing images. */
|
|
113
|
+
export function stacksRuntimeFindings({ repoRoot, files, doc, edition = 'full' }) {
|
|
114
|
+
const findings = [], lite = edition === 'lite', components = mapping(doc?.components), environments = mapping(doc?.environments);
|
|
115
|
+
const deployments = swarmDeploymentFiles(repoRoot, files), sources = new Set();
|
|
116
|
+
findings.push(...swarmInventoryIssues({ components, lite }).map(issue => issueFinding(DECLARATION, issue)));
|
|
117
|
+
if (lite) findings.push(...liteEnvironmentFindings({ doc, environments, deployments }));
|
|
118
|
+
for (const [environment, entry] of Object.entries(environments)) {
|
|
119
|
+
const spec = mapping(entry);
|
|
120
|
+
if (spec.runtime !== 'docker-swarm') continue;
|
|
121
|
+
const loaded = loadSwarmModel({ repoRoot, environment, spec, files, findings });
|
|
122
|
+
for (const file of loaded.sources) sources.add(file);
|
|
123
|
+
const file = `.starcistacks/${environment}/${spec.composeFiles?.[0] ?? 'infra/swarm/stack.yaml'}`;
|
|
124
|
+
findings.push(...swarmModelIssues({ model: loaded.model, components, bindings: spec.components, lite }).map(issue => issueFinding(file, issue)));
|
|
125
|
+
}
|
|
126
|
+
findings.push(...undeclaredSwarmFindings(deployments, environments, sources));
|
|
127
|
+
return findings;
|
|
128
|
+
}
|