@starci/hfs 4.0.10 → 4.1.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 +1 -1
- package/emit/operations.mjs +1 -1
- package/emit/type-schema.mjs +2 -1
- package/lint/run.mjs +3 -2
- package/package.json +1 -1
- package/report/order.mjs +2 -0
- package/runtime/config.example.yaml +187 -0
- package/runtime/engine/by-code-unit.mjs +12 -0
- package/runtime/engine/config.mjs +221 -239
- package/runtime/engine/invalid-config.mjs +5 -5
- package/runtime/engine/model-config.mjs +90 -0
- package/runtime/engine/orca-config.mjs +3 -1
- package/runtime/engine/removed-vocabulary.mjs +28 -0
- package/runtime/engine/resources-config.mjs +52 -0
- package/runtime/engine/runtime-root.mjs +17 -0
- package/runtime/engine/secrets.mjs +35 -31
- package/runtime/engine/sonar-config.mjs +21 -0
- package/runtime/engine/temp-root.mjs +32 -0
- package/runtime/knowledge/hfs/canon-pins.yaml +14 -14
- package/runtime/knowledge/hfs/peer-integrations.yaml +2 -3
- package/runtime/knowledge/hfs/rules.yaml +98 -65
- package/runtime/knowledge/hfs/slots.yaml +11 -14
- package/runtime/knowledge/patterns/be/api.yaml +7 -24
- package/runtime/knowledge/patterns/be/cli.yaml +3 -12
- package/runtime/knowledge/patterns/be/realtime.yaml +1 -14
- package/runtime/knowledge/patterns/be/webhooks.yaml +0 -19
- package/runtime/modules/kernel/failure-codes.yaml +1 -41
- package/runtime/modules/kernel/removed-vocabulary.yaml +168 -0
- package/runtime/modules/models/registry.yaml +25 -359
- package/runtime/modules/models/runtimes.yaml +92 -256
- package/runtime/modules/models/tiers.yaml +65 -0
- package/runtime/scripts/api/fs/claim-file.mjs +30 -0
- package/runtime/scripts/api/fs/ensure-temp-root.mjs +27 -0
- package/runtime/scripts/api/fs/forbidden-root.mjs +2 -1
- package/runtime/scripts/api/fs/make-temp-dir.mjs +11 -0
- package/runtime/scripts/api/fs/safe-remove.mjs +52 -35
- package/runtime/scripts/api/git/lib.mjs +34 -1
- package/runtime/scripts/api/process/resolve-real-tool.mjs +14 -8
- package/runtime/scripts/api/process/run-program.mjs +2 -1
- package/runtime/scripts/api/sops/lib.mjs +198 -120
- package/runtime/scripts/hfs/allows.mjs +3 -3
- package/runtime/scripts/hfs/architecture/ast-walks.mjs +63 -50
- package/runtime/scripts/hfs/architecture/automatic-gates.mjs +96 -0
- package/runtime/scripts/hfs/architecture/backend.mjs +261 -175
- package/runtime/scripts/hfs/architecture/background-unowned.mjs +69 -34
- package/runtime/scripts/hfs/architecture/client-reaches-server.mjs +55 -54
- package/runtime/scripts/hfs/architecture/clones.mjs +179 -106
- package/runtime/scripts/hfs/architecture/config-unread.mjs +5 -22
- package/runtime/scripts/hfs/architecture/config.mjs +95 -80
- package/runtime/scripts/hfs/architecture/connection-map.mjs +233 -141
- package/runtime/scripts/hfs/architecture/constructor-deps.mjs +15 -9
- package/runtime/scripts/hfs/architecture/context-coupling.mjs +76 -52
- package/runtime/scripts/hfs/architecture/context-map.mjs +215 -141
- package/runtime/scripts/hfs/architecture/context-owner.mjs +39 -30
- package/runtime/scripts/hfs/architecture/context-platform-tables.mjs +31 -17
- package/runtime/scripts/hfs/architecture/context-transaction.mjs +38 -25
- package/runtime/scripts/hfs/architecture/contract-fixture-guard.mjs +86 -40
- package/runtime/scripts/hfs/architecture/contracts-readonly.mjs +342 -0
- package/runtime/scripts/hfs/architecture/contracts.mjs +263 -455
- package/runtime/scripts/hfs/architecture/cross-app-duplicate.mjs +43 -34
- package/runtime/scripts/hfs/architecture/dead-exports.mjs +201 -154
- package/runtime/scripts/hfs/architecture/default-deny.mjs +118 -93
- package/runtime/scripts/hfs/architecture/doc-language.mjs +2 -1
- package/runtime/scripts/hfs/architecture/entrypoint.mjs +45 -30
- package/runtime/scripts/hfs/architecture/error-codes.mjs +19 -12
- package/runtime/scripts/hfs/architecture/error-masked.mjs +39 -26
- package/runtime/scripts/hfs/architecture/fe-slot-allows.mjs +2 -1
- package/runtime/scripts/hfs/architecture/feature-shape.mjs +26 -20
- package/runtime/scripts/hfs/architecture/framework-pinned.mjs +1 -1
- package/runtime/scripts/hfs/architecture/frontend-grammar.mjs +190 -0
- package/runtime/scripts/hfs/architecture/frontend-routing.mjs +186 -0
- package/runtime/scripts/hfs/architecture/frontend-world-render.mjs +311 -0
- package/runtime/scripts/hfs/architecture/frontend-world.mjs +328 -0
- package/runtime/scripts/hfs/architecture/frontend.mjs +109 -829
- package/runtime/scripts/hfs/architecture/hfs-graph.mjs +14 -2
- package/runtime/scripts/hfs/architecture/hfs.mjs +120 -288
- package/runtime/scripts/hfs/architecture/hooks-are-hooks.mjs +46 -26
- package/runtime/scripts/hfs/architecture/i18n-keys.mjs +114 -76
- package/runtime/scripts/hfs/architecture/index.mjs +158 -102
- package/runtime/scripts/hfs/architecture/injection-token-exported.mjs +33 -28
- package/runtime/scripts/hfs/architecture/machine-ast.mjs +180 -144
- package/runtime/scripts/hfs/architecture/managed-scripts.mjs +8 -2
- package/runtime/scripts/hfs/architecture/module-per-transport.mjs +150 -101
- package/runtime/scripts/hfs/architecture/next-data-contract.mjs +51 -43
- package/runtime/scripts/hfs/architecture/next-data-key.mjs +338 -0
- package/runtime/scripts/hfs/architecture/next-data.mjs +179 -427
- package/runtime/scripts/hfs/architecture/owners.mjs +27 -13
- package/runtime/scripts/hfs/architecture/package-shape.mjs +51 -23
- package/runtime/scripts/hfs/architecture/presentation.mjs +165 -0
- package/runtime/scripts/hfs/architecture/reachability.mjs +127 -61
- package/runtime/scripts/hfs/architecture/register-once.mjs +112 -63
- package/runtime/scripts/hfs/architecture/registration.mjs +187 -110
- package/runtime/scripts/hfs/architecture/required-files.mjs +100 -68
- package/runtime/scripts/hfs/architecture/route-files-thin.mjs +83 -58
- package/runtime/scripts/hfs/architecture/schema-owner.mjs +182 -108
- package/runtime/scripts/hfs/architecture/source-names-shape.mjs +318 -0
- package/runtime/scripts/hfs/architecture/source-names.mjs +83 -248
- package/runtime/scripts/hfs/architecture/sql-owner.mjs +175 -118
- package/runtime/scripts/hfs/architecture/sql-returning.mjs +39 -28
- package/runtime/scripts/hfs/architecture/sql-tokens.mjs +302 -172
- package/runtime/scripts/hfs/architecture/supabase-ast.mjs +1 -1
- package/runtime/scripts/hfs/architecture/supabase-be.mjs +55 -45
- package/runtime/scripts/hfs/architecture/supabase-results.mjs +182 -0
- package/runtime/scripts/hfs/architecture/supabase-tables.mjs +20 -12
- package/runtime/scripts/hfs/architecture/supabase.mjs +191 -263
- package/runtime/scripts/hfs/architecture/symbols.mjs +175 -99
- package/runtime/scripts/hfs/architecture/test-world-files.mjs +82 -41
- package/runtime/scripts/hfs/architecture/tiers.mjs +120 -69
- package/runtime/scripts/hfs/architecture/transport-owner.mjs +84 -57
- package/runtime/scripts/hfs/architecture/type-context.mjs +282 -0
- package/runtime/scripts/hfs/architecture/typescript.mjs +116 -276
- package/runtime/scripts/hfs/architecture/unit-spec-providers.mjs +84 -62
- package/runtime/scripts/hfs/architecture.mjs +3 -4
- package/runtime/scripts/hfs/check.mjs +61 -70
- package/runtime/scripts/hfs/coverage-scope.mjs +84 -38
- package/runtime/scripts/hfs/declaration-shape.mjs +77 -36
- package/runtime/scripts/hfs/declaration-slots.mjs +1 -1
- package/runtime/scripts/hfs/edition-slots.mjs +5 -3
- package/runtime/scripts/hfs/linear-text.mjs +30 -0
- package/runtime/scripts/hfs/manifest-shape.mjs +164 -63
- package/runtime/scripts/hfs/path-findings.mjs +58 -38
- package/runtime/scripts/hfs/pin-findings.mjs +31 -0
- package/runtime/scripts/hfs/repo-identity.mjs +5 -2
- package/runtime/scripts/hfs/rule-catalog.mjs +194 -0
- package/runtime/scripts/hfs/rule-params-shape.mjs +33 -27
- package/runtime/scripts/hfs/rules/cli.mjs +5 -1
- package/runtime/scripts/hfs/rules/contract-compat.mjs +40 -22
- package/runtime/scripts/hfs/rules/contract.mjs +35 -27
- package/runtime/scripts/hfs/rules/database-config.mjs +57 -32
- package/runtime/scripts/hfs/rules/database-migrations.mjs +52 -31
- package/runtime/scripts/hfs/rules/database-plpgsql.mjs +106 -91
- package/runtime/scripts/hfs/rules/database-sql.mjs +254 -173
- package/runtime/scripts/hfs/rules/database.mjs +36 -23
- package/runtime/scripts/hfs/rules/deps.mjs +64 -32
- package/runtime/scripts/hfs/rules/docker.mjs +113 -65
- package/runtime/scripts/hfs/rules/edition.mjs +118 -111
- package/runtime/scripts/hfs/rules/event-bus.mjs +91 -45
- package/runtime/scripts/hfs/rules/fe-contract-documents.mjs +17 -16
- package/runtime/scripts/hfs/rules/fe-no-tests.mjs +36 -16
- package/runtime/scripts/hfs/rules/frontend-tree.mjs +4 -3
- package/runtime/scripts/hfs/rules/integration-specs.mjs +75 -41
- package/runtime/scripts/hfs/rules/kinds.mjs +59 -25
- package/runtime/scripts/hfs/rules/lint-suppression.mjs +9 -3
- package/runtime/scripts/hfs/rules/monorepo.mjs +82 -45
- package/runtime/scripts/hfs/rules/peer-integrations.mjs +24 -3
- package/runtime/scripts/hfs/rules/pipeline.mjs +58 -9
- package/runtime/scripts/hfs/rules/proof-commands.mjs +14 -8
- package/runtime/scripts/hfs/rules/repo-local-checks.mjs +11 -6
- package/runtime/scripts/hfs/rules/saga.mjs +128 -61
- package/runtime/scripts/hfs/rules/secrets.mjs +4 -3
- package/runtime/scripts/hfs/rules/services.mjs +34 -16
- package/runtime/scripts/hfs/rules/stacks.mjs +21 -14
- package/runtime/scripts/hfs/rules/supabase-secrets.mjs +32 -23
- package/runtime/scripts/hfs/rules/test-topology.mjs +25 -11
- package/runtime/scripts/hfs/secret.mjs +59 -48
- package/runtime/scripts/hfs/slot-app-view.mjs +102 -0
- package/runtime/scripts/hfs/slot-classify.mjs +65 -0
- package/runtime/scripts/hfs/slot-errors.mjs +11 -0
- package/runtime/scripts/hfs/slot-imports.mjs +76 -0
- package/runtime/scripts/hfs/slot-manifest-shape.mjs +73 -0
- package/runtime/scripts/hfs/slot-match.mjs +153 -0
- package/runtime/scripts/hfs/slot-path.mjs +8 -0
- package/runtime/scripts/hfs/slot-required.mjs +76 -0
- package/runtime/scripts/hfs/slot-semantic-problems.mjs +148 -0
- package/runtime/scripts/hfs/slot-side-problems.mjs +28 -0
- package/runtime/scripts/hfs/slots.mjs +72 -693
- package/runtime/scripts/hfs/sql/pg-parse.mjs +5 -2
- package/runtime/scripts/hfs/trailing-slashes.mjs +8 -0
- package/runtime/scripts/hfs/tree.mjs +19 -14
- package/runtime/scripts/hfs/typescript-programs.mjs +6 -4
- package/runtime/scripts/hfs/view.mjs +1 -1
- package/runtime/scripts/lib/dockerfile.mjs +56 -33
- package/runtime/scripts/lib/event-contract.mjs +17 -13
- package/runtime/scripts/lib/git.mjs +2 -2
- package/runtime/scripts/lib/graphql-contract.mjs +158 -322
- package/runtime/scripts/lib/graphql-sdl.mjs +262 -0
- package/runtime/scripts/lib/i18n.mjs +3 -2
- package/runtime/scripts/lib/in-order.mjs +71 -0
- package/runtime/scripts/lib/language.mjs +3 -3
- package/runtime/scripts/lib/list.mjs +8 -1
- package/runtime/scripts/lib/path-key.mjs +38 -5
- package/runtime/scripts/lib/pid-alive.mjs +7 -0
- package/runtime/scripts/lib/regex.mjs +1 -1
- package/runtime/scripts/lib/same-text.mjs +1 -1
- package/runtime/scripts/lib/secret-patterns.mjs +6 -2
- package/runtime/scripts/lib/sleep-sync.mjs +2 -2
- package/runtime/scripts/lib/sops-envelope.mjs +95 -48
- package/runtime/scripts/lib/stack-services.mjs +1 -2
- package/runtime/scripts/lib/ts-ast.mjs +9 -0
- package/runtime/scripts/lib/walk.mjs +6 -1
- package/scaffold/add-cli-lite.mjs +2 -1
- package/scaffold/add-table.mjs +2 -2
- package/scaffold/add.mjs +3 -2
- package/scaffold/app.mjs +4 -2
- package/scaffold/edition-gate.mjs +25 -25
- package/scaffold/lite-exports.mjs +2 -1
- package/scaffold/service.mjs +5 -4
- package/sync/hygiene.mjs +15 -9
- package/sync/index.mjs +7 -3
- package/templates/app/ci-workflows/github/workflows/ci.yml +4 -4
- package/templates/app/ci-workflows/github/workflows/e2e.yml +3 -3
- package/templates/app/ci-workflows/github/workflows/images.yml +3 -3
- package/templates/app/ci-workflows/sonar-steps.yml +2 -2
- package/templates/app/ci-workflows-lite/github/workflows/ci.yml +6 -6
- package/templates/app/ci-workflows-lite/github/workflows/db-deploy.yml +3 -3
- package/templates/app/ci-workflows-lite/github/workflows/images.yml +3 -3
- package/templates/app/starciwork.gitignore +1 -1
- package/templates/be/image/api/Dockerfile +1 -1
- package/templates/be/image/cli/Dockerfile +1 -1
- package/templates/be/image/worker/Dockerfile +1 -1
- package/templates/be/patterns/cli/group.cli.spec.ts.tpl +1 -1
- package/templates/be/patterns/cli/group.cli.ts.tpl +2 -2
- package/templates/be/patterns/event-bus/platform/event-runner.service.spec.ts.tpl +13 -0
- package/templates/be/patterns/event-bus/platform/event-runner.service.ts.tpl +7 -2
- package/templates/be/patterns/event-bus/platform/event.policy.ts.tpl +4 -1
- package/templates/be/patterns/event-bus/platform/kafka-event-transport.client.ts.tpl +2 -1
- package/templates/be/patterns/outbox/platform/outbox-relay.policy.ts.tpl +24 -9
- package/templates/be/patterns/queues/platform/queue-relay.service.ts.tpl +3 -2
- package/templates/be/patterns/queues/platform/queue-worker.service.ts.tpl +8 -5
- package/templates/be/skeleton/src/features/cli/migrate/migrate.cli.spec.ts +1 -1
- package/templates/be/skeleton/src/features/cli/migrate/migrate.cli.ts +2 -2
- package/templates/be/skeleton/src/features/cli/seed/seed.cli.spec.ts +1 -1
- package/templates/be/skeleton/src/features/cli/seed/seed.cli.ts +2 -2
- package/templates/be/skeleton/src/modules/platform/database/migrate-connections.client.ts +3 -2
- package/templates/be/skeleton/src/modules/platform/database/seed-connections.client.ts +5 -4
- package/templates/be/skeleton/src/modules/platform/primitives/index.ts +1 -0
- package/templates/be/skeleton/src/modules/platform/primitives/sequence.policy.spec.ts +47 -0
- package/templates/be/skeleton/src/modules/platform/primitives/sequence.policy.ts +16 -0
- package/templates/be/skeleton-lite/apps/api/Dockerfile +1 -0
- package/templates/be/skeleton-lite/src/modules/platform/primitives/index.ts +2 -0
- package/templates/fe/image/next/Dockerfile +2 -1
- package/templates/fe/skeleton/apps/app/src/app/[locale]/page.tsx +2 -2
- package/templates/fe/skeleton/apps/landing/src/app/[locale]/page.tsx +2 -2
- package/templates/fe/skeleton/packages/__project__-i18n/src/index.ts +1 -2
- package/templates/fe/skeleton-lite/apps/web/src/app/[locale]/layout.tsx +1 -2
- package/templates/fe/skeleton-lite/apps/web/src/app/[locale]/page.tsx +2 -2
- package/templates/fe/skeleton-lite/apps/web/src/components/blocks/SignInForm/index.tsx +7 -1
- package/templates/fe/skeleton-lite/apps/web/src/modules/db/auth/write-sign-out.ts +1 -1
- package/templates/fe/skeleton-lite/apps/web/src/modules/db/validation/validation.mapper.ts +10 -2
- package/templates/fe/skeleton-lite/apps/web/src/modules/i18n/request.ts +10 -3
- package/upgrade/index.mjs +9 -2
- package/runtime/engine/admission.mjs +0 -308
- package/runtime/engine/canonical-json.mjs +0 -10
- package/runtime/engine/db/blob.mjs +0 -315
- package/runtime/engine/db/ledger-paths.mjs +0 -83
- package/runtime/engine/db/ledger.mjs +0 -1118
- package/runtime/engine/db/machine-connection.mjs +0 -135
- package/runtime/engine/db/machine-schema.mjs +0 -84
- package/runtime/engine/db/machine.mjs +0 -1367
- package/runtime/engine/db/migrations/machine/0001-init.sql +0 -924
- package/runtime/engine/db/migrations/runtime/0001-init.sql +0 -1108
- package/runtime/engine/db/provider-reservations.mjs +0 -101
- package/runtime/engine/digest.mjs +0 -16
- package/runtime/engine/refuse.mjs +0 -11
- package/runtime/modules/ops/_labels.yaml +0 -53
- package/runtime/scripts/api/node/lib.mjs +0 -14
- package/runtime/scripts/api/node/spawn-node.mjs +0 -6
- package/runtime/scripts/api/process/lib.mjs +0 -111
- package/runtime/scripts/api/process/owned-process.mjs +0 -78
- package/runtime/scripts/api/process/stop-owned-process.mjs +0 -9
- package/runtime/scripts/connectors/lib.mjs +0 -488
- package/runtime/scripts/lib/clip.mjs +0 -19
- package/runtime/scripts/lib/display-names.mjs +0 -259
- package/runtime/scripts/lib/example-refs.mjs +0 -158
- package/runtime/scripts/lib/json-schema.mjs +0 -52
- package/runtime/scripts/lib/process-identity.mjs +0 -6
- package/runtime/scripts/lib/read-yaml.mjs +0 -18
- package/runtime/scripts/lib/redact.mjs +0 -161
- package/runtime/scripts/lib/sleep.mjs +0 -7
- package/runtime/scripts/lib/source-phrases.mjs +0 -34
- package/runtime/scripts/lib/sqlite.mjs +0 -21
|
@@ -18,48 +18,80 @@ const HOISTED = /^node_modules\/((?:@[^/]+\/)?[^/]+)$/;
|
|
|
18
18
|
/** Every package.json a repository tracks: the root and each workspace's. */
|
|
19
19
|
const manifestsOf = (files) => files.filter((file) => file === 'package.json' || (file.endsWith('/package.json') && !file.includes('node_modules/')));
|
|
20
20
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
const
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
at.get(spec).push(file);
|
|
35
|
-
}
|
|
36
|
-
}
|
|
21
|
+
function recordDependencySpec(specs, file, name, spec) {
|
|
22
|
+
if (typeof spec !== 'string' || LINK.test(spec)) return;
|
|
23
|
+
if (!specs.has(name)) specs.set(name, new Map());
|
|
24
|
+
const at = specs.get(name);
|
|
25
|
+
if (!at.has(spec)) at.set(spec, []);
|
|
26
|
+
at.get(spec).push(file);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function collectManifestSpecs(repoRoot, file, specs) {
|
|
30
|
+
const pkg = readJson(repoRoot, file);
|
|
31
|
+
if (!pkg) return;
|
|
32
|
+
for (const section of SECTIONS) {
|
|
33
|
+
for (const [name, spec] of Object.entries(pkg[section] ?? {})) recordDependencySpec(specs, file, name, spec);
|
|
37
34
|
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function versionSkewFindings(specs) {
|
|
38
|
+
const findings = [];
|
|
38
39
|
for (const [name, bySpec] of specs) {
|
|
39
40
|
if (bySpec.size < 2) continue;
|
|
40
41
|
const list = [...bySpec].map(([spec, where]) => `${spec} (${where.join(', ')})`);
|
|
41
42
|
findings.push(found(DEP_VERSION_SKEW, [...bySpec.values()][0][0], `${name} is declared at ${bySpec.size} versions in the workspace: ${list.join('; ')}; keep one`, { dependency: name, versions: [...bySpec.keys()] }));
|
|
42
43
|
}
|
|
43
|
-
|
|
44
|
+
return findings;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function overrideFindings(specs, overrides) {
|
|
48
|
+
const findings = [];
|
|
44
49
|
for (const [name, pin] of Object.entries(overrides && typeof overrides === 'object' ? overrides : {})) {
|
|
45
50
|
if (typeof pin !== 'string' || pin.startsWith('$') || !specs.has(name)) continue;
|
|
46
51
|
const off = [...specs.get(name)].filter(([spec]) => spec !== pin);
|
|
47
|
-
if (off.length)
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
if (lock?.packages) {
|
|
51
|
-
const declared = new Set(specs.keys());
|
|
52
|
-
const hoisted = new Map();
|
|
53
|
-
for (const [key, entry] of Object.entries(lock.packages)) {
|
|
54
|
-
const top = HOISTED.exec(key);
|
|
55
|
-
if (top && !entry.link) hoisted.set(top[1], entry.version);
|
|
56
|
-
}
|
|
57
|
-
for (const [key, entry] of Object.entries(lock.packages)) {
|
|
58
|
-
const nested = NESTED.exec(key);
|
|
59
|
-
if (!nested || entry.link || entry.inBundle || HOISTED.test(key) || !declared.has(nested[1])) continue;
|
|
60
|
-
const name = nested[1];
|
|
61
|
-
findings.push(found(DEP_VERSION_SKEW, 'package-lock.json', `${key} is a nested copy of ${name}${entry.version ? ` ${entry.version}` : ''}${hoisted.has(name) ? ` next to the hoisted ${hoisted.get(name)}` : ''}; the workspace keeps one copy (align the ranges or add a root override)`, { dependency: name, lockPath: key, version: entry.version }));
|
|
52
|
+
if (off.length) {
|
|
53
|
+
const declarations = off.map(([spec, where]) => `${spec} (${where.join(', ')})`).join('; ');
|
|
54
|
+
findings.push(found(DEP_VERSION_SKEW, off[0][1][0], `${name} is pinned to ${pin} by the root overrides but declared at ${declarations}; declare the pinned version`, { dependency: name, versions: off.map(([spec]) => spec), pinned: pin }));
|
|
62
55
|
}
|
|
63
56
|
}
|
|
64
57
|
return findings;
|
|
65
58
|
}
|
|
59
|
+
|
|
60
|
+
/** The versions of the top-level (hoisted) packages of a lockfile, by package name. */
|
|
61
|
+
function hoistedVersions(packages) {
|
|
62
|
+
const hoisted = new Map();
|
|
63
|
+
for (const [key, entry] of Object.entries(packages)) {
|
|
64
|
+
const top = HOISTED.exec(key);
|
|
65
|
+
if (top && !entry.link) hoisted.set(top[1], entry.version);
|
|
66
|
+
}
|
|
67
|
+
return hoisted;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The finding for a lockfile entry that nests a second copy of a declared dependency, or null. */
|
|
71
|
+
function nestedCopyFinding(key, entry, declared, hoisted) {
|
|
72
|
+
const nested = NESTED.exec(key);
|
|
73
|
+
if (!nested || entry.link || entry.inBundle || HOISTED.test(key) || !declared.has(nested[1])) return null;
|
|
74
|
+
const name = nested[1];
|
|
75
|
+
const versionNote = entry.version ? ` ${entry.version}` : '';
|
|
76
|
+
const hoistedNote = hoisted.has(name) ? ` next to the hoisted ${hoisted.get(name)}` : '';
|
|
77
|
+
return found(DEP_VERSION_SKEW, 'package-lock.json', `${key} is a nested copy of ${name}${versionNote}${hoistedNote}; the workspace keeps one copy (align the ranges or add a root override)`, { dependency: name, lockPath: key, version: entry.version });
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function nestedLockFindings(specs, lock) {
|
|
81
|
+
if (!lock?.packages) return [];
|
|
82
|
+
const declared = new Set(specs.keys());
|
|
83
|
+
const hoisted = hoistedVersions(lock.packages);
|
|
84
|
+
return Object.entries(lock.packages).map(([key, entry]) => nestedCopyFinding(key, entry, declared, hoisted)).filter(Boolean);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The findings of R14 over the tracked paths `files` of `repoRoot`. */
|
|
88
|
+
export function depFindings({ repoRoot, files }) {
|
|
89
|
+
const specs = new Map();
|
|
90
|
+
for (const file of manifestsOf(files)) collectManifestSpecs(repoRoot, file, specs);
|
|
91
|
+
const findings = versionSkewFindings(specs);
|
|
92
|
+
const overrides = readJson(repoRoot, 'package.json')?.overrides;
|
|
93
|
+
findings.push(...overrideFindings(specs, overrides));
|
|
94
|
+
const lock = files.includes('package-lock.json') ? readJson(repoRoot, 'package-lock.json') : null;
|
|
95
|
+
findings.push(...nestedLockFindings(specs, lock));
|
|
96
|
+
return findings;
|
|
97
|
+
}
|
|
@@ -78,49 +78,52 @@ function envOf(stage) {
|
|
|
78
78
|
return env;
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
+
/** True when a header comment states `docker build -f <wanted> ... .`. */
|
|
82
|
+
const statesBuildCommand = (parsed, wanted) => parsed.comments.some((comment) => {
|
|
83
|
+
const parts = words(comment.text);
|
|
84
|
+
const build = parts.findIndex((part, index) => part === 'docker' && parts[index + 1] === 'build');
|
|
85
|
+
if (build < 0) return false;
|
|
86
|
+
const flag = parts.indexOf('-f', build);
|
|
87
|
+
return flag > 0 && parts[flag + 1] === wanted && parts.at(-1) === '.';
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
function copySourceFindings(file, item) {
|
|
91
|
+
const findings = [];
|
|
92
|
+
const parts = words(item.text).filter((part) => !part.startsWith('--'));
|
|
93
|
+
const sources = parts.slice(0, -1);
|
|
94
|
+
const fromStage = words(item.text).some((part) => part.startsWith('--from='));
|
|
95
|
+
if (fromStage) return findings;
|
|
96
|
+
for (const source of sources) {
|
|
97
|
+
if (/^[a-z]+:\/\//i.test(source)) continue;
|
|
98
|
+
const segments = source.split('/');
|
|
99
|
+
if (source.startsWith('/') || segments.includes('..')) findings.push(found(DOCKER_BUILD_CONTEXT, file, `${file}:${item.line} ${item.keyword} source ${source} leaves the build context; every source is a path inside the app root.`, { line: item.line, source }));
|
|
100
|
+
}
|
|
101
|
+
return findings;
|
|
102
|
+
}
|
|
103
|
+
|
|
81
104
|
/** R187: the header states this app's build command, and the build never reaches outside its context. */
|
|
82
105
|
function contextFindings(file, parsed, side, app) {
|
|
83
106
|
const findings = [];
|
|
84
107
|
const wanted = dockerfilePath(side, app);
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
const build = parts.findIndex((part, index) => part === 'docker' && parts[index + 1] === 'build');
|
|
88
|
-
if (build < 0) return false;
|
|
89
|
-
const flag = parts.indexOf('-f', build);
|
|
90
|
-
return flag > 0 && parts[flag + 1] === wanted && parts.at(-1) === '.';
|
|
91
|
-
});
|
|
92
|
-
if (!named) findings.push(found(DOCKER_BUILD_CONTEXT, file, `${file} does not state its build command in a header comment: \`docker build -f ${wanted} -t <image> .\` (the context is the app root, the folder with the one package.json and lockfile).`, { expected: wanted }));
|
|
93
|
-
for (const item of parsed.instructions.filter((entry) => entry.keyword === 'COPY' || entry.keyword === 'ADD')) {
|
|
94
|
-
const parts = words(item.text).filter((part) => !part.startsWith('--'));
|
|
95
|
-
const sources = parts.slice(0, -1);
|
|
96
|
-
const fromStage = words(item.text).some((part) => part.startsWith('--from='));
|
|
97
|
-
if (fromStage) continue;
|
|
98
|
-
for (const source of sources) {
|
|
99
|
-
if (/^[a-z]+:\/\//i.test(source)) continue;
|
|
100
|
-
const segments = source.split('/');
|
|
101
|
-
if (source.startsWith('/') || segments.includes('..')) findings.push(found(DOCKER_BUILD_CONTEXT, file, `${file}:${item.line} ${item.keyword} source ${source} leaves the build context; every source is a path inside the app root.`, { line: item.line, source }));
|
|
102
|
-
}
|
|
103
|
-
}
|
|
108
|
+
if (!statesBuildCommand(parsed, wanted)) findings.push(found(DOCKER_BUILD_CONTEXT, file, `${file} does not state its build command in a header comment: \`docker build -f ${wanted} -t <image> .\` (the context is the app root, the folder with the one package.json and lockfile).`, { expected: wanted }));
|
|
109
|
+
for (const item of parsed.instructions.filter((entry) => entry.keyword === 'COPY' || entry.keyword === 'ADD')) findings.push(...copySourceFindings(file, item));
|
|
104
110
|
return findings;
|
|
105
111
|
}
|
|
106
112
|
|
|
107
113
|
/** R188: the stages and the install, the user and the build discipline of each. */
|
|
108
|
-
function
|
|
114
|
+
function stageInstallFindings(file, stages) {
|
|
109
115
|
const findings = [];
|
|
110
|
-
const { stages } = parsed;
|
|
111
|
-
const names = stages.map((stage) => stage.name);
|
|
112
|
-
if (!names.includes(BUILD_STAGE) || stages.at(-1)?.name !== RUNTIME_STAGE || stages.length < 2) {
|
|
113
|
-
findings.push(found(DOCKER_STAGES, file, `${file} has stages ${JSON.stringify(names)}; an image is multi-stage: a stage named ${BUILD_STAGE} (installs and builds) and a last stage named ${RUNTIME_STAGE} (what ships).`, { stages: names }));
|
|
114
|
-
if (!stages.length) return findings;
|
|
115
|
-
}
|
|
116
116
|
for (const stage of stages) {
|
|
117
117
|
for (const command of commandsOf(stage)) {
|
|
118
118
|
const call = npmCall(command.words);
|
|
119
119
|
if (call && (call.verb === 'install' || call.verb === 'i') && !call.args.some((arg) => arg === '-g' || arg === '--global')) findings.push(found(DOCKER_STAGES, file, `${file}:${command.line} runs \`npm ${call.verb}\`; an image installs with \`npm ci\` from the lockfile.`, { line: command.line }));
|
|
120
120
|
}
|
|
121
121
|
}
|
|
122
|
-
|
|
123
|
-
|
|
122
|
+
return findings;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function runtimeStageFindings(file, runtime, side, kind) {
|
|
126
|
+
const findings = [];
|
|
124
127
|
for (const command of commandsOf(runtime)) {
|
|
125
128
|
if (isBuildCommand(command.words)) findings.push(found(DOCKER_STAGES, file, `${file}:${command.line} the ${RUNTIME_STAGE} stage runs the build (\`${command.words.join(' ')}\`); it only receives what the ${BUILD_STAGE} stage built.`, { line: command.line }));
|
|
126
129
|
}
|
|
@@ -132,43 +135,79 @@ function stageFindings(file, parsed, side, kind) {
|
|
|
132
135
|
return findings;
|
|
133
136
|
}
|
|
134
137
|
|
|
138
|
+
function stageFindings(file, parsed, side, kind) {
|
|
139
|
+
const findings = [];
|
|
140
|
+
const { stages } = parsed;
|
|
141
|
+
const names = stages.map((stage) => stage.name);
|
|
142
|
+
if (!names.includes(BUILD_STAGE) || stages.at(-1)?.name !== RUNTIME_STAGE || stages.length < 2) {
|
|
143
|
+
findings.push(found(DOCKER_STAGES, file, `${file} has stages ${JSON.stringify(names)}; an image is multi-stage: a stage named ${BUILD_STAGE} (installs and builds) and a last stage named ${RUNTIME_STAGE} (what ships).`, { stages: names }));
|
|
144
|
+
if (!stages.length) return findings;
|
|
145
|
+
}
|
|
146
|
+
findings.push(...stageInstallFindings(file, stages));
|
|
147
|
+
const runtime = stages.at(-1);
|
|
148
|
+
if (runtime.name !== RUNTIME_STAGE) return findings;
|
|
149
|
+
findings.push(...runtimeStageFindings(file, runtime, side, kind));
|
|
150
|
+
return findings;
|
|
151
|
+
}
|
|
152
|
+
|
|
135
153
|
/** The Next config's `output` property, read with TypeScript's parser: the string literal assigned to `output` in an object literal. */
|
|
136
154
|
function nextOutput(ts, text) {
|
|
137
155
|
return propertyText(ts, text, { file: 'next.config.ts', key: 'output' });
|
|
138
156
|
}
|
|
139
157
|
|
|
158
|
+
function listenerHealthFindings(file, runtime, exposes, health, none) {
|
|
159
|
+
const findings = [];
|
|
160
|
+
const port = envOf(runtime).get('PORT');
|
|
161
|
+
if (!port || !exposes.includes(port)) findings.push(found(DOCKER_ENTRY, file, `${file} ${RUNTIME_STAGE} stage must set \`ENV PORT=<port>\` and \`EXPOSE\` the same port (found PORT ${JSON.stringify(port ?? null)}, EXPOSE ${JSON.stringify(exposes)}); a listening app declares the port it serves.`, { port: port ?? null, exposes }));
|
|
162
|
+
if (!health.length || none) findings.push(found(DOCKER_ENTRY, file, `${file} ${RUNTIME_STAGE} stage has no HEALTHCHECK; an api or Next app answers a health probe the platform can run.`, {}));
|
|
163
|
+
return findings;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function oneShotHealthFindings(file, kind, exposes, none) {
|
|
167
|
+
const findings = [];
|
|
168
|
+
if (!none) findings.push(found(DOCKER_ENTRY, file, `${file} ${RUNTIME_STAGE} stage must say \`HEALTHCHECK NONE\`; a ${kind} app is a one-shot command, not a service.`, { kind }));
|
|
169
|
+
if (exposes.length) findings.push(found(DOCKER_ENTRY, file, `${file} ${RUNTIME_STAGE} stage EXPOSEs ${exposes.join(', ')}; a ${kind} app listens on nothing.`, { exposes }));
|
|
170
|
+
return findings;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function processHealthFindings(file, kind, exposes, health, none) {
|
|
174
|
+
const findings = [];
|
|
175
|
+
if (!health.length || none) findings.push(found(DOCKER_ENTRY, file, `${file} ${RUNTIME_STAGE} stage has no process HEALTHCHECK; a ${kind} app serves nothing, so its healthcheck probes its process.`, { kind }));
|
|
176
|
+
if (exposes.length) findings.push(found(DOCKER_ENTRY, file, `${file} ${RUNTIME_STAGE} stage EXPOSEs ${exposes.join(', ')}; a ${kind} app listens on nothing.`, { exposes }));
|
|
177
|
+
return findings;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function entryHealthFindings(file, runtime, side, kind, exposes, health, none) {
|
|
181
|
+
if (side === 'fe' || BE_KINDS_WITH_LISTENER.has(kind)) return listenerHealthFindings(file, runtime, exposes, health, none);
|
|
182
|
+
if (BE_KINDS_ONE_SHOT.has(kind)) return oneShotHealthFindings(file, kind, exposes, none);
|
|
183
|
+
return processHealthFindings(file, kind, exposes, health, none);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function nextEntryFindings({ repoRoot, file, parsed, app, project, ts }) {
|
|
187
|
+
const findings = [];
|
|
188
|
+
const build = parsed.stages.find((stage) => stage.name === BUILD_STAGE);
|
|
189
|
+
const filter = `--filter=@${project}/${app}`;
|
|
190
|
+
const builds = build && commandsOf(build).some((command) => command.words.some((word) => word === 'turbo' || word.endsWith('/turbo')) && command.words.includes('build') && command.words.includes(filter));
|
|
191
|
+
if (!builds) findings.push(found(DOCKER_ENTRY, file, `${file} ${BUILD_STAGE} stage does not run \`turbo run build ${filter}\`; a Next image builds its own workspace (and the packages it imports) through turbo.`, { expected: filter }));
|
|
192
|
+
const config = `fe/apps/${app}/next.config.ts`;
|
|
193
|
+
const text = readText(repoRoot, config);
|
|
194
|
+
if (text !== null && ts && nextOutput(ts, text) !== 'standalone') findings.push(found(DOCKER_ENTRY, config, `${config} does not set \`output: "standalone"\`; the runtime stage of ${file} ships the standalone output.`, { expected: 'standalone' }));
|
|
195
|
+
return findings;
|
|
196
|
+
}
|
|
197
|
+
|
|
140
198
|
/** R189: the entry, the port and the health of the runtime. */
|
|
141
199
|
function entryFindings({ repoRoot, file, parsed, side, app, kind, project, ts }) {
|
|
142
200
|
const findings = [];
|
|
143
201
|
const runtime = parsed.stages.at(-1);
|
|
144
|
-
if (
|
|
202
|
+
if (runtime?.name !== RUNTIME_STAGE) return findings;
|
|
145
203
|
const entry = side === 'be' ? beEntry(app) : feEntry(app);
|
|
146
204
|
const starts = [...instructionsOf(runtime, 'ENTRYPOINT'), ...instructionsOf(runtime, 'CMD')].map((item) => execForm(item.text));
|
|
147
|
-
if (!starts.some((exec) => exec
|
|
205
|
+
if (!starts.some((exec) => exec?.[0] === 'node' && exec[1] === entry)) findings.push(found(DOCKER_ENTRY, file, `${file} ${RUNTIME_STAGE} stage does not start \`["node", "${entry}"]\` in exec form (CMD or ENTRYPOINT); the app runs its own built entry.`, { expected: entry }));
|
|
148
206
|
const exposes = instructionsOf(runtime, 'EXPOSE').flatMap((item) => words(item.text).map((port) => port.replace(/\/(tcp|udp)$/i, '')));
|
|
149
207
|
const health = instructionsOf(runtime, 'HEALTHCHECK').map((item) => words(item.text));
|
|
150
208
|
const none = health.some((parts) => parts[0]?.toUpperCase() === 'NONE');
|
|
151
|
-
|
|
152
|
-
if (
|
|
153
|
-
const port = envOf(runtime).get('PORT');
|
|
154
|
-
if (!port || !exposes.includes(port)) findings.push(found(DOCKER_ENTRY, file, `${file} ${RUNTIME_STAGE} stage must set \`ENV PORT=<port>\` and \`EXPOSE\` the same port (found PORT ${JSON.stringify(port ?? null)}, EXPOSE ${JSON.stringify(exposes)}); a listening app declares the port it serves.`, { port: port ?? null, exposes }));
|
|
155
|
-
if (!health.length || none) findings.push(found(DOCKER_ENTRY, file, `${file} ${RUNTIME_STAGE} stage has no HEALTHCHECK; an api or Next app answers a health probe the platform can run.`, {}));
|
|
156
|
-
} else if (BE_KINDS_ONE_SHOT.has(kind)) {
|
|
157
|
-
if (!none) findings.push(found(DOCKER_ENTRY, file, `${file} ${RUNTIME_STAGE} stage must say \`HEALTHCHECK NONE\`; a ${kind} app is a one-shot command, not a service.`, { kind }));
|
|
158
|
-
if (exposes.length) findings.push(found(DOCKER_ENTRY, file, `${file} ${RUNTIME_STAGE} stage EXPOSEs ${exposes.join(', ')}; a ${kind} app listens on nothing.`, { exposes }));
|
|
159
|
-
} else {
|
|
160
|
-
if (!health.length || none) findings.push(found(DOCKER_ENTRY, file, `${file} ${RUNTIME_STAGE} stage has no process HEALTHCHECK; a ${kind} app serves nothing, so its healthcheck probes its process.`, { kind }));
|
|
161
|
-
if (exposes.length) findings.push(found(DOCKER_ENTRY, file, `${file} ${RUNTIME_STAGE} stage EXPOSEs ${exposes.join(', ')}; a ${kind} app listens on nothing.`, { exposes }));
|
|
162
|
-
}
|
|
163
|
-
if (side === 'fe') {
|
|
164
|
-
const build = parsed.stages.find((stage) => stage.name === BUILD_STAGE);
|
|
165
|
-
const filter = `--filter=@${project}/${app}`;
|
|
166
|
-
const builds = build && commandsOf(build).some((command) => command.words.includes('turbo') && command.words.includes('build') && command.words.includes(filter));
|
|
167
|
-
if (!builds) findings.push(found(DOCKER_ENTRY, file, `${file} ${BUILD_STAGE} stage does not run \`turbo run build ${filter}\`; a Next image builds its own workspace (and the packages it imports) through turbo.`, { expected: filter }));
|
|
168
|
-
const config = `fe/apps/${app}/next.config.ts`;
|
|
169
|
-
const text = readText(repoRoot, config);
|
|
170
|
-
if (text !== null && ts && nextOutput(ts, text) !== 'standalone') findings.push(found(DOCKER_ENTRY, config, `${config} does not set \`output: "standalone"\`; the runtime stage of ${file} ships the standalone output.`, { expected: 'standalone' }));
|
|
171
|
-
}
|
|
209
|
+
findings.push(...entryHealthFindings(file, runtime, side, kind, exposes, health, none));
|
|
210
|
+
if (side === 'fe') findings.push(...nextEntryFindings({ repoRoot, file, parsed, app, project, ts }));
|
|
172
211
|
return findings;
|
|
173
212
|
}
|
|
174
213
|
|
|
@@ -180,7 +219,7 @@ function resolvedImage(parsed, image) {
|
|
|
180
219
|
if (name && rest.length) defaults.set(name, rest.join('='));
|
|
181
220
|
}
|
|
182
221
|
let unresolved = false;
|
|
183
|
-
const text = image.replace(/\$\{([A-Za-z_]
|
|
222
|
+
const text = image.replace(/\$\{([A-Za-z_]\w*)\}|\$([A-Za-z_]\w*)/g, (whole, braced, bare) => {
|
|
184
223
|
const value = defaults.get(braced ?? bare);
|
|
185
224
|
if (value === undefined) unresolved = true;
|
|
186
225
|
return value ?? whole;
|
|
@@ -209,23 +248,32 @@ const isSecretPath = (source) => {
|
|
|
209
248
|
return env || segments.some((segment) => SECRET_FOLDERS.has(segment)) || SECRET_SUFFIXES.some((suffix) => base.endsWith(suffix)) || base.startsWith('kubeconfig');
|
|
210
249
|
};
|
|
211
250
|
|
|
251
|
+
function copySecretFindings(file, item) {
|
|
252
|
+
const findings = [];
|
|
253
|
+
const parts = words(item.text);
|
|
254
|
+
if (parts.some((part) => part.startsWith('--from='))) return findings;
|
|
255
|
+
const sources = parts.filter((part) => !part.startsWith('--')).slice(0, -1);
|
|
256
|
+
for (const source of sources) {
|
|
257
|
+
if (/^https?:\/\//i.test(source)) findings.push(found(DOCKER_SECRETS, file, `${file}:${item.line} ADD of a URL (${source}); a download is a checksum-verified stage, never an ADD.`, { line: item.line, source }));
|
|
258
|
+
else if (isSecretPath(source)) findings.push(found(DOCKER_SECRETS, file, `${file}:${item.line} ${item.keyword} of ${source}, which holds secret material; credentials are mounted at run time, never copied into an image.`, { line: item.line, source }));
|
|
259
|
+
}
|
|
260
|
+
return findings;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function variableSecretFindings(file, item) {
|
|
264
|
+
const findings = [];
|
|
265
|
+
const parts = words(item.text);
|
|
266
|
+
const names = item.keyword === 'ENV' && parts.length >= 2 && !parts[0].includes('=') ? [parts[0]] : parts.map((part) => part.split('=')[0]);
|
|
267
|
+
for (const name of names) if (isCredentialName(name)) findings.push(found(DOCKER_SECRETS, file, `${file}:${item.line} ${item.keyword} ${name} names a credential; an image carries none (an ARG or ENV stays in the image history). Mount it at run time; only NEXT_PUBLIC_* values, which are published by design, are build arguments.`, { line: item.line, name }));
|
|
268
|
+
return findings;
|
|
269
|
+
}
|
|
270
|
+
|
|
212
271
|
/** R191: no secret enters an image. */
|
|
213
272
|
function secretFindings(file, parsed) {
|
|
214
273
|
const findings = [];
|
|
215
274
|
for (const item of parsed.instructions) {
|
|
216
|
-
if (item.keyword === 'COPY' || item.keyword === 'ADD')
|
|
217
|
-
|
|
218
|
-
if (parts.some((part) => part.startsWith('--from='))) continue;
|
|
219
|
-
const sources = parts.filter((part) => !part.startsWith('--')).slice(0, -1);
|
|
220
|
-
for (const source of sources) {
|
|
221
|
-
if (/^https?:\/\//i.test(source)) findings.push(found(DOCKER_SECRETS, file, `${file}:${item.line} ADD of a URL (${source}); a download is a checksum-verified stage, never an ADD.`, { line: item.line, source }));
|
|
222
|
-
else if (isSecretPath(source)) findings.push(found(DOCKER_SECRETS, file, `${file}:${item.line} ${item.keyword} of ${source}, which holds secret material; credentials are mounted at run time, never copied into an image.`, { line: item.line, source }));
|
|
223
|
-
}
|
|
224
|
-
} else if (item.keyword === 'ARG' || item.keyword === 'ENV') {
|
|
225
|
-
const parts = words(item.text);
|
|
226
|
-
const names = item.keyword === 'ENV' && parts.length >= 2 && !parts[0].includes('=') ? [parts[0]] : parts.map((part) => part.split('=')[0]);
|
|
227
|
-
for (const name of names) if (isCredentialName(name)) findings.push(found(DOCKER_SECRETS, file, `${file}:${item.line} ${item.keyword} ${name} names a credential; an image carries none (an ARG or ENV stays in the image history). Mount it at run time; only NEXT_PUBLIC_* values, which are published by design, are build arguments.`, { line: item.line, name }));
|
|
228
|
-
}
|
|
275
|
+
if (item.keyword === 'COPY' || item.keyword === 'ADD') findings.push(...copySecretFindings(file, item));
|
|
276
|
+
else if (item.keyword === 'ARG' || item.keyword === 'ENV') findings.push(...variableSecretFindings(file, item));
|
|
229
277
|
}
|
|
230
278
|
return findings;
|
|
231
279
|
}
|
|
@@ -1,111 +1,118 @@
|
|
|
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. The slot layer already answers the paths lite
|
|
3
|
-
// removed (slots lite excludes, or whose litePresence is forbidden, come back HFS_FORBIDDEN_PRESENT with their goesTo); this
|
|
4
|
-
// rule reports what no path can state:
|
|
5
|
-
// - inside a package.json: a test script (`test`, `test:*`, `typecheck:tests`, a jest/vitest/playwright/mocha/cypress or
|
|
6
|
-
// `node --test` command) or a jest/vitest/playwright dependency - the same vocabulary FE_NO_TESTS scans on the fe side;
|
|
7
|
-
// - a test-tool configuration file no more specific slot already forbids;
|
|
8
|
-
// - a declaration of what lite does not have: a worker app, an event pattern (event-bus, queue, fenced-job, projection,
|
|
9
|
-
// saga) or its trigger kinds (saga, reactors, jobs, realtime). The forbidden names are DERIVED - a pattern, trigger or
|
|
10
|
-
// app kind is gone under lite exactly when every slot that carries it is litePresence: forbidden - so adding or
|
|
11
|
-
// restoring a kind is a manifest edit, not an edit here.
|
|
12
|
-
// The edition is never tested here: a full app's view forbids none of these names and keeps its tests, so the rule answers [].
|
|
13
|
-
import { found, readJson } from './read.mjs';
|
|
14
|
-
import { DEPENDENCY_SECTIONS, TEST_DEPENDENCY, TEST_RUNNER_COMMAND, TEST_SCRIPT_NAME, TEST_TOOL_FILE } from './fe-no-tests.mjs';
|
|
15
|
-
|
|
16
|
-
export const HFS_EDITION_FORBIDDEN_PRESENT = 'HFS_EDITION_FORBIDDEN_PRESENT';
|
|
17
|
-
|
|
18
|
-
const PACKAGE_JSON = /(?:^|\/)package\.json$/;
|
|
19
|
-
const TYPECHECK_TESTS = /^(?:pre|post)?typecheck:tests$/;
|
|
20
|
-
|
|
21
|
-
/**
|
|
22
|
-
* The names the edition cannot declare: for each of `pattern`, `trigger` and `appKind`, the value is gone when every slot of
|
|
23
|
-
* the resolver's view that carries it is forbidden there. The view is the edition's own (full forbids none of them), so the
|
|
24
|
-
* rule needs no edition test: a full app has nothing gone.
|
|
25
|
-
*/
|
|
26
|
-
function editionGone(resolver) {
|
|
27
|
-
const tally = new Map();
|
|
28
|
-
for (const slot of resolver.slots()) {
|
|
29
|
-
const forbidden = slot.presence === 'forbidden';
|
|
30
|
-
for (const field of ['pattern', 'trigger', 'appKind']) {
|
|
31
|
-
if (slot[field] === undefined) continue;
|
|
32
|
-
const key = `${field}:${slot[field]}`;
|
|
33
|
-
const entry = tally.get(key) ?? { total: 0, forbidden: 0 };
|
|
34
|
-
entry.total += 1;
|
|
35
|
-
entry.forbidden += forbidden ? 1 : 0;
|
|
36
|
-
tally.set(key, entry);
|
|
37
|
-
}
|
|
38
|
-
}
|
|
39
|
-
const gone = { pattern: new Set(), trigger: new Set(), appKind: new Set() };
|
|
40
|
-
for (const [key, entry] of tally) {
|
|
41
|
-
if (entry.total !== entry.forbidden) continue;
|
|
42
|
-
const [field, name] = key.split(':');
|
|
43
|
-
gone[field].add(name);
|
|
44
|
-
}
|
|
45
|
-
return gone;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
for (const section of DEPENDENCY_SECTIONS) {
|
|
60
|
-
const deps = pkg[section];
|
|
61
|
-
if (
|
|
62
|
-
for (const name of Object.keys(deps)) {
|
|
63
|
-
if (TEST_DEPENDENCY.test(name)) {
|
|
64
|
-
findings.push(found(HFS_EDITION_FORBIDDEN_PRESENT, rel, `${rel} ${section} has ${name}: the lite edition has no tests - the dependency belongs to the full edition`, { section, name }));
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
return findings;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
/** The findings one
|
|
72
|
-
function
|
|
73
|
-
const
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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. The slot layer already answers the paths lite
|
|
3
|
+
// removed (slots lite excludes, or whose litePresence is forbidden, come back HFS_FORBIDDEN_PRESENT with their goesTo); this
|
|
4
|
+
// rule reports what no path can state:
|
|
5
|
+
// - inside a package.json: a test script (`test`, `test:*`, `typecheck:tests`, a jest/vitest/playwright/mocha/cypress or
|
|
6
|
+
// `node --test` command) or a jest/vitest/playwright dependency - the same vocabulary FE_NO_TESTS scans on the fe side;
|
|
7
|
+
// - a test-tool configuration file no more specific slot already forbids;
|
|
8
|
+
// - a declaration of what lite does not have: a worker app, an event pattern (event-bus, queue, fenced-job, projection,
|
|
9
|
+
// saga) or its trigger kinds (saga, reactors, jobs, realtime). The forbidden names are DERIVED - a pattern, trigger or
|
|
10
|
+
// app kind is gone under lite exactly when every slot that carries it is litePresence: forbidden - so adding or
|
|
11
|
+
// restoring a kind is a manifest edit, not an edit here.
|
|
12
|
+
// The edition is never tested here: a full app's view forbids none of these names and keeps its tests, so the rule answers [].
|
|
13
|
+
import { found, readJson } from './read.mjs';
|
|
14
|
+
import { DEPENDENCY_SECTIONS, TEST_DEPENDENCY, TEST_RUNNER_COMMAND, TEST_SCRIPT_NAME, TEST_TOOL_FILE } from './fe-no-tests.mjs';
|
|
15
|
+
|
|
16
|
+
export const HFS_EDITION_FORBIDDEN_PRESENT = 'HFS_EDITION_FORBIDDEN_PRESENT';
|
|
17
|
+
|
|
18
|
+
const PACKAGE_JSON = /(?:^|\/)package\.json$/;
|
|
19
|
+
const TYPECHECK_TESTS = /^(?:pre|post)?typecheck:tests$/;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The names the edition cannot declare: for each of `pattern`, `trigger` and `appKind`, the value is gone when every slot of
|
|
23
|
+
* the resolver's view that carries it is forbidden there. The view is the edition's own (full forbids none of them), so the
|
|
24
|
+
* rule needs no edition test: a full app has nothing gone.
|
|
25
|
+
*/
|
|
26
|
+
function editionGone(resolver) {
|
|
27
|
+
const tally = new Map();
|
|
28
|
+
for (const slot of resolver.slots()) {
|
|
29
|
+
const forbidden = slot.presence === 'forbidden';
|
|
30
|
+
for (const field of ['pattern', 'trigger', 'appKind']) {
|
|
31
|
+
if (slot[field] === undefined) continue;
|
|
32
|
+
const key = `${field}:${slot[field]}`;
|
|
33
|
+
const entry = tally.get(key) ?? { total: 0, forbidden: 0 };
|
|
34
|
+
entry.total += 1;
|
|
35
|
+
entry.forbidden += forbidden ? 1 : 0;
|
|
36
|
+
tally.set(key, entry);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
const gone = { pattern: new Set(), trigger: new Set(), appKind: new Set() };
|
|
40
|
+
for (const [key, entry] of tally) {
|
|
41
|
+
if (entry.total !== entry.forbidden) continue;
|
|
42
|
+
const [field, name] = key.split(':');
|
|
43
|
+
gone[field].add(name);
|
|
44
|
+
}
|
|
45
|
+
return gone;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const isPlainObject = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
49
|
+
|
|
50
|
+
function testScriptFindings(rel, pkg) {
|
|
51
|
+
const scripts = isPlainObject(pkg.scripts) ? pkg.scripts : {};
|
|
52
|
+
return Object.entries(scripts)
|
|
53
|
+
.filter(([name, command]) => TEST_SCRIPT_NAME.test(name) || TYPECHECK_TESTS.test(name) || TEST_RUNNER_COMMAND.test(String(command)))
|
|
54
|
+
.map(([name]) => found(HFS_EDITION_FORBIDDEN_PRESENT, rel, `${rel} has script ${JSON.stringify(name)}: the lite edition has no tests - a lite app does not typecheck or run a test world`, { name }));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function testDependencyFindings(rel, pkg) {
|
|
58
|
+
const findings = [];
|
|
59
|
+
for (const section of DEPENDENCY_SECTIONS) {
|
|
60
|
+
const deps = pkg[section];
|
|
61
|
+
if (!isPlainObject(deps)) continue;
|
|
62
|
+
for (const name of Object.keys(deps)) {
|
|
63
|
+
if (TEST_DEPENDENCY.test(name)) {
|
|
64
|
+
findings.push(found(HFS_EDITION_FORBIDDEN_PRESENT, rel, `${rel} ${section} has ${name}: the lite edition has no tests - the dependency belongs to the full edition`, { section, name }));
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return findings;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The findings one package.json's scripts and dependencies produce under lite. */
|
|
72
|
+
function packageFindings(repoRoot, rel) {
|
|
73
|
+
const pkg = readJson(repoRoot, rel);
|
|
74
|
+
if (!isPlainObject(pkg)) return [];
|
|
75
|
+
return [...testScriptFindings(rel, pkg), ...testDependencyFindings(rel, pkg)];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const goneAppFindings = (repo, gone) => (repo.apps ?? [])
|
|
79
|
+
.filter((app) => gone.appKind.has(app.kind))
|
|
80
|
+
.map((app) => found(HFS_EDITION_FORBIDDEN_PRESENT, 'hfs.json', `hfs.json declares the ${app.kind} app ${app.name}: a ${app.kind} belongs to the full edition, a lite app has none`, { app: app.name, kind: app.kind }));
|
|
81
|
+
|
|
82
|
+
const goneNameFindings = (field, verb, declared, goneNames) => declared
|
|
83
|
+
.filter((name) => goneNames.has(name))
|
|
84
|
+
.map((name) => found(HFS_EDITION_FORBIDDEN_PRESENT, 'hfs.json', `hfs.json ${verb} the ${name} ${field}: ${name} belongs to the full edition, a lite app has none`, { name }));
|
|
85
|
+
|
|
86
|
+
/** The findings one be-side declaration produces under lite: a worker app, an event pattern or a gone trigger kind. */
|
|
87
|
+
function declarationFindings(repo, gone) {
|
|
88
|
+
return [
|
|
89
|
+
...goneAppFindings(repo, gone),
|
|
90
|
+
...goneNameFindings('pattern', 'declares', repo.patterns ?? [], gone.pattern),
|
|
91
|
+
...goneNameFindings('trigger', 'names', repo.kinds ?? [], gone.trigger),
|
|
92
|
+
];
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* L01 for one scope (the app root or one side), over `files` (scope-relative). Both questions are answered by the
|
|
97
|
+
* resolver's view of the edition: the names gone from it, and whether any slot of it still has tests. The declaration
|
|
98
|
+
* findings are judged from the be side (its apps, patterns and kinds): the root scope looks at sides.be, a standalone
|
|
99
|
+
* be-side check at its own view. `withDeclaration: false` (a side scope of a whole-app check) skips them: the root scope
|
|
100
|
+
* already reported them on hfs.json, and the side scope would only repeat them on a path that is not this side's.
|
|
101
|
+
*/
|
|
102
|
+
export function editionFindings({ repoRoot, files, repo, resolver, withDeclaration = true }) {
|
|
103
|
+
const gone = editionGone(resolver);
|
|
104
|
+
const noTestWorld = resolver.slots().every((slot) => slot.tests === 'none');
|
|
105
|
+
let beView = null;
|
|
106
|
+
if (withDeclaration && repo.profile === 'app') beView = repo.sides?.be;
|
|
107
|
+
else if (withDeclaration && repo.profile === 'be') beView = repo;
|
|
108
|
+
const findings = beView ? declarationFindings(beView, gone) : [];
|
|
109
|
+
for (const file of noTestWorld ? files : []) {
|
|
110
|
+
if (file.includes('node_modules/')) continue;
|
|
111
|
+
if (PACKAGE_JSON.test(file)) {
|
|
112
|
+
findings.push(...packageFindings(repoRoot, file));
|
|
113
|
+
} else if (TEST_TOOL_FILE.test(file) && resolver.classifyPath(file).status !== 'forbidden') {
|
|
114
|
+
findings.push(found(HFS_EDITION_FORBIDDEN_PRESENT, file, `${file} is test tooling: the lite edition has no tests - the file belongs to the full edition`));
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return findings;
|
|
118
|
+
}
|