@starci/hfs 4.0.9 → 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/LICENSE +21 -0
- package/emit/operations.mjs +1 -1
- package/emit/type-schema.mjs +2 -1
- package/lint/run.mjs +82 -8
- package/package.json +3 -2
- 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 +247 -273
- 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 +134 -0
- package/runtime/engine/sonar-config.mjs +21 -0
- package/runtime/engine/temp-root.mjs +32 -0
- package/runtime/knowledge/hfs/canon-pins.yaml +18 -18
- package/runtime/knowledge/hfs/peer-integrations.yaml +2 -3
- package/runtime/knowledge/hfs/rules.yaml +102 -69
- 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 -1
- package/runtime/modules/kernel/removed-vocabulary.yaml +168 -0
- package/runtime/modules/models/registry.yaml +25 -397
- package/runtime/modules/models/runtimes.yaml +92 -262
- 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/lib.mjs +6 -0
- 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 +36 -1
- package/runtime/scripts/api/process/resolve-real-tool.mjs +52 -0
- package/runtime/scripts/api/process/run-program.mjs +8 -0
- package/runtime/scripts/api/sops/decrypt.mjs +9 -16
- package/runtime/scripts/api/sops/lib.mjs +309 -11
- package/runtime/scripts/api/sops/seal.mjs +8 -4
- 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 +3 -2
- 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 +90 -45
- 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/fs-kind.mjs +14 -4
- 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/mutation-fence.mjs +15 -0
- package/runtime/scripts/lib/path-key.mjs +42 -9
- 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 +103 -2
- 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
|
@@ -15,8 +15,8 @@ import { relativePath } from './typescript.mjs';
|
|
|
15
15
|
* graph.ownerRoots Map<unitKey, {slot, root, tier}> of every owner instance that holds a graph file
|
|
16
16
|
* graph.abs(file) the repository-relative path of an absolute file the graph holds, else null
|
|
17
17
|
*/
|
|
18
|
-
|
|
19
|
-
|
|
18
|
+
// Every production source file the TypeScript context loaded, as a graph.files node keyed by its repository-relative path.
|
|
19
|
+
const collectFiles = (config, context, resolver) => {
|
|
20
20
|
const files = new Map();
|
|
21
21
|
const absolute = new Map();
|
|
22
22
|
for (const sourceFile of context.files) {
|
|
@@ -35,6 +35,11 @@ export function buildHfsGraph(config, context) {
|
|
|
35
35
|
});
|
|
36
36
|
absolute.set(file, rel);
|
|
37
37
|
}
|
|
38
|
+
return { files, absolute };
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
// Every import, re-export and type-only import between two graphed files, as rel-path edges.
|
|
42
|
+
const collectEdges = (context, absolute) => {
|
|
38
43
|
const edges = [];
|
|
39
44
|
for (const [file, list] of context.edges) {
|
|
40
45
|
const from = absolute.get(file);
|
|
@@ -45,6 +50,13 @@ export function buildHfsGraph(config, context) {
|
|
|
45
50
|
edges.push({ from, to, runtime: edge.runtime, line: edge.line, column: edge.column, specifier: edge.specifier, reexport: Boolean(edge.reexport), edge });
|
|
46
51
|
}
|
|
47
52
|
}
|
|
53
|
+
return edges;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export function buildHfsGraph(config, context) {
|
|
57
|
+
const resolver = config.hfs;
|
|
58
|
+
const { files, absolute } = collectFiles(config, context, resolver);
|
|
59
|
+
const edges = collectEdges(context, absolute);
|
|
48
60
|
const unit = rel => {
|
|
49
61
|
const node = files.get(rel);
|
|
50
62
|
if (!node) return null;
|
|
@@ -1,13 +1,12 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { lsFiles } from '../../api/git/ls-files.mjs'; import { revParseQuery } from '../../api/git/rev-parse-query.mjs'; import { configGet } from '../../api/git/config-get.mjs'; import { gitOutputOf } from '../../lib/git.mjs';
|
|
4
|
-
import { repositoryName } from '../repo-identity.mjs';
|
|
5
4
|
import { braceVariants } from '../../lib/glob.mjs';
|
|
6
5
|
import { createSlotResolver, loadSlotManifest, openHfs } from '../slots.mjs';
|
|
7
6
|
import { isFeTestPath } from '../rules/fe-no-tests.mjs';
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
|
|
7
|
+
import { byCodeUnit } from '../../lib/list.mjs';
|
|
8
|
+
import { checkRepoPresentationImpl } from './presentation.mjs';
|
|
9
|
+
import { e2eInAutomaticGate, testTreesOutOfDefaultProgram } from './automatic-gates.mjs';
|
|
11
10
|
/**
|
|
12
11
|
* HFS repository-tree check (knowledge/hfs/README.md): every StarCi repository is an
|
|
13
12
|
* apps/<app>/ monorepo on npm with a fixed root-entry allowlist; backend composition lives in
|
|
@@ -43,15 +42,20 @@ export const HFS_RULE_IDS = [
|
|
|
43
42
|
];
|
|
44
43
|
|
|
45
44
|
const NON_NPM_ENTRIES = new Set(['pnpm-lock.yaml', 'pnpm-workspace.yaml', 'yarn.lock', 'bun.lock', 'bun.lockb']);
|
|
46
|
-
const
|
|
47
|
-
const PRODUCT_ROOT_MARKDOWN = new Set(['README.md']);
|
|
48
|
-
const README_SECTIONS = ['Overview', 'Stack', 'Repository layout', 'Development'];
|
|
45
|
+
const HUSKY_HOOKS_PATH = /^\.husky(?:\/_)?\/?$/u;
|
|
49
46
|
const BACKEND_SRC_CHILDREN = new Set(['features', 'modules', 'tests']);
|
|
50
47
|
const moduleTiersOf = resolver => new Set(resolver.slots().filter(slot => slot.profiles.includes('be')).flatMap(slot => braceVariants(slot.path)).map(variant => /^src\/modules\/([a-z][a-z-]*)\//.exec(variant)?.[1]).filter(Boolean)); // the folders of src/modules/ the slot manifest knows (domain, platform, integrations, events, queues, projections)
|
|
51
48
|
// Owner test layout 2026-09-30: unit `<name>.spec.ts`, integration, e2e and contract by folder and suffix; int-spec and harness-spec stay banned.
|
|
52
|
-
const
|
|
53
|
-
const
|
|
54
|
-
const
|
|
49
|
+
const UNSUPPORTED_TEST_SUFFIX = /\.(?:int|harness)-spec\.[cm]?[jt]sx?$/u;
|
|
50
|
+
const UNSUPPORTED_TEST_FOLDER = /^src\/tests\/(?:harness|live|e2e\/live)(?:\/|$)/u;
|
|
51
|
+
const JEST_CONFIG_PREFIX = /^jest[.-]/u;
|
|
52
|
+
const JEST_CONFIG_SUFFIX = /(?:config\.[cm]?[jt]s|\.json)$/u;
|
|
53
|
+
const JEST_LANE_PREFIX = /^jest-(?:e2e|int|harness)/u;
|
|
54
|
+
/** A per-lane jest config file name: the last path segment is `jest[.-]...config.<ext>`, `jest[.-]....json` or `jest-<lane>...`. */
|
|
55
|
+
const isExtraTestConfig = file => {
|
|
56
|
+
const base = file.slice(file.lastIndexOf('/') + 1);
|
|
57
|
+
return (JEST_CONFIG_PREFIX.test(base) && JEST_CONFIG_SUFFIX.test(base.slice('jest-'.length))) || JEST_LANE_PREFIX.test(base);
|
|
58
|
+
};
|
|
55
59
|
const NODE_ENTRIES_SKIPPED = new Set(['node_modules', '.git']);
|
|
56
60
|
|
|
57
61
|
/** The slot resolver of a side folder: the caller's, else the side view its app's hfs.json declares, else the profile's slots with no app declared. */
|
|
@@ -109,6 +113,17 @@ function ownsGitTopLevel(root) {
|
|
|
109
113
|
}
|
|
110
114
|
}
|
|
111
115
|
|
|
116
|
+
/** A directory that is not the top level of its own work tree has no hooks of its own: the check is not applicable there. */
|
|
117
|
+
function hooksPathNotRedirected({ root, finding }) {
|
|
118
|
+
if (!ownsGitTopLevel(root)) return { status: 'not-applicable', reason: 'the checked root is not the top level of its own Git work tree, so it has no hooks of its own' };
|
|
119
|
+
const set = configGet(root, 'core.hooksPath', { local: true });
|
|
120
|
+
if (!set.ok) return { status: 'checked' }; // key not set: nothing is redirected
|
|
121
|
+
const value = set.stdout;
|
|
122
|
+
if (value && !HUSKY_HOOKS_PATH.test(value.replaceAll('\\', '/')))
|
|
123
|
+
finding('HFS_HOOKS_PATH_REDIRECTED', '.git/config', `core.hooksPath is set to ${value} in this clone. Unset it (git config --local --unset core.hooksPath) and let husky own the hooks; a redirected path skips the pre-commit and pre-push gates.`);
|
|
124
|
+
return { status: 'checked' };
|
|
125
|
+
}
|
|
126
|
+
|
|
112
127
|
/** The tracked paths under the given root, relative to it: the pathspec keeps a nested directory from listing the paths of its enclosing clone. */
|
|
113
128
|
function gitPaths(root) {
|
|
114
129
|
try {
|
|
@@ -182,214 +197,109 @@ function treeView(root) {
|
|
|
182
197
|
};
|
|
183
198
|
}
|
|
184
199
|
|
|
185
|
-
|
|
186
|
-
function
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
if (
|
|
192
|
-
|
|
193
|
-
if (
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
200
|
+
/** Presentation checks shared by the product HFS gate and this runtime's own standalone gate. */
|
|
201
|
+
export function checkRepoPresentation({ root, runtime = false, tree = treeView(root), profile = 'app', edition = 'full' }) {
|
|
202
|
+
return checkRepoPresentationImpl({ root, runtime, tree, profile, edition, nonNpmEntries: NON_NPM_ENTRIES });
|
|
203
|
+
}
|
|
204
|
+
function checkSideRootEntry(state, entry) {
|
|
205
|
+
const { profile, frontend, backend, allowed, finding } = state;
|
|
206
|
+
if (NON_NPM_ENTRIES.has(entry) || /\.md$/iu.test(entry)) return;
|
|
207
|
+
if (entry === '.starcistacks') { finding('HFS_STACKS_IN_SIDE', entry, `The ${profile} side must not hold .starcistacks; stack declarations and sealed custody live in the app root .starcistacks.`); return; }
|
|
208
|
+
if (frontend && !backend) {
|
|
209
|
+
if (entry === '.starciwork') { finding('HFS_WORK_IN_FE', entry, 'The fe side must not hold a .starciwork tree; Work records live in the app root .starciwork.'); return; }
|
|
210
|
+
if (entry === 'src') { finding('HFS_ROOT_SRC_FORBIDDEN_FE', entry, 'The fe side keeps source only under apps/<app>/src; the fe/src/ tree must move.'); return; }
|
|
211
|
+
if (isFeTestPath(entry) || isFeTestPath(`${entry}/x`)) return;
|
|
212
|
+
}
|
|
213
|
+
if (!allowed.has(entry)) finding('HFS_ROOT_ENTRY_FORBIDDEN', entry, `Root entry ${entry} of the ${profile} side is not in the slots of the ${profile} side.`);
|
|
197
214
|
}
|
|
198
215
|
|
|
199
|
-
|
|
200
|
-
//
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
function scriptCommand(name) {
|
|
204
|
-
const escaped = name.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&');
|
|
205
|
-
return new RegExp(name === 'test' ? 'npm (?:run test|test)(?![\\w:-])' : `npm run ${escaped}(?![\\w:-])`, 'u');
|
|
216
|
+
function checkSideRootEntries(state) {
|
|
217
|
+
// The README, the hooks, the workflows and the scripts are the app root's (checkAppRoot); a side folder is judged as the old
|
|
218
|
+
// repository root it stands for, less those.
|
|
219
|
+
for (const entry of [...state.tree.top].sort(byCodeUnit)) checkSideRootEntry(state, entry);
|
|
206
220
|
}
|
|
207
221
|
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
for (const entry of tree.top) {
|
|
213
|
-
if (/\.md$/iu.test(entry) && !(runtime ? RUNTIME_ROOT_MARKDOWN : PRODUCT_ROOT_MARKDOWN).has(entry))
|
|
214
|
-
finding('HFS_ROOT_MARKDOWN_FORBIDDEN', entry, `Root Markdown ${entry} belongs under docs/ or the owning Work record.`);
|
|
215
|
-
if (NON_NPM_ENTRIES.has(entry))
|
|
216
|
-
finding('HFS_PACKAGE_MANAGER_MIXED', entry, `${entry} contradicts the npm package manager contract.`);
|
|
217
|
-
}
|
|
218
|
-
for (const entry of ['.gitattributes', 'README.md']) {
|
|
219
|
-
if (!tree.hasFile(entry)) finding('HFS_ROOT_ENTRY_MISSING', entry, `Repository presentation requires root ${entry}.`);
|
|
220
|
-
}
|
|
221
|
-
if (tree.hasFile('package.json')) {
|
|
222
|
-
try {
|
|
223
|
-
const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
|
|
224
|
-
if (pkg.packageManager && !/^npm@\d/u.test(pkg.packageManager))
|
|
225
|
-
finding('HFS_PACKAGE_MANAGER_MIXED', 'package.json', `packageManager ${pkg.packageManager} contradicts the npm package-lock.json contract.`);
|
|
226
|
-
} catch { /* The repository's package/config checks own unreadable or invalid JSON. */ }
|
|
227
|
-
}
|
|
228
|
-
if (!tree.hasFile('README.md')) return { violations, coverage: { status: 'checked', source: tree.source } };
|
|
229
|
-
let readme;
|
|
230
|
-
try { readme = fs.readFileSync(path.join(root, 'README.md'), 'utf8'); }
|
|
231
|
-
catch {
|
|
232
|
-
finding('HFS_ROOT_ENTRY_MISSING', 'README.md', 'Tracked README.md is not readable.');
|
|
233
|
-
return { violations, coverage: { status: 'checked', source: tree.source } };
|
|
234
|
-
}
|
|
235
|
-
const lines = readme.split(/\r?\n/u);
|
|
236
|
-
const name = runtime ? 'StarCi' : repositoryName(root);
|
|
237
|
-
if (lines[0].trim().toLowerCase() !== `# ${name}`.toLowerCase())
|
|
238
|
-
finding('HFS_README_TITLE_INVALID', 'README.md', `README.md must start with # ${name}.`);
|
|
239
|
-
const description = lines.slice(1).find(line => line.trim());
|
|
240
|
-
if (!description || /^\s*(?:#|!\[|\[!\[)/u.test(description) || description.trim().length > 240)
|
|
241
|
-
finding('HFS_README_DESCRIPTION_INVALID', 'README.md', 'Place one concise description line directly below the repository name.');
|
|
242
|
-
const headings = lines.map((line, index) => ({ name: /^## (.+?)\s*$/u.exec(line)?.[1], index })).filter(item => item.name);
|
|
243
|
-
const required = [...README_SECTIONS, ...(tree.hasDir('.starciwork') ? ['Work'] : [])];
|
|
244
|
-
let previous = -1;
|
|
245
|
-
for (const section of required) {
|
|
246
|
-
const found = headings.find(item => item.name === section);
|
|
247
|
-
if (!found) finding('HFS_README_SECTION_MISSING', 'README.md', `README.md requires a ## ${section} section.`);
|
|
248
|
-
else if (found.index <= previous) finding('HFS_README_SECTION_ORDER', 'README.md', `## ${section} must follow the preceding standard section.`, found.index + 1);
|
|
249
|
-
else previous = found.index;
|
|
250
|
-
}
|
|
251
|
-
const sectionBody = section => {
|
|
252
|
-
const start = headings.find(item => item.name === section)?.index;
|
|
253
|
-
if (start === undefined) return '';
|
|
254
|
-
const end = headings.find(item => item.index > start)?.index ?? lines.length;
|
|
255
|
-
return lines.slice(start + 1, end).join('\n');
|
|
256
|
-
};
|
|
257
|
-
if (!runtime && headings.some(item => item.name === 'Development')) {
|
|
258
|
-
const development = sectionBody('Development');
|
|
259
|
-
const scripts = DEVELOPMENT_SCRIPTS.filter(name => managedScriptNames(profile, edition).has(name));
|
|
260
|
-
const commands = [/npm (?:ci|install)/u, ...scripts.map(scriptCommand)];
|
|
261
|
-
if (commands.some(command => !command.test(development)))
|
|
262
|
-
finding('HFS_README_DEVELOPMENT_INCOMPLETE', 'README.md',
|
|
263
|
-
`Development must show npm install and the managed script commands: ${scripts.map(name => (name === 'test' ? 'npm test' : `npm run ${name}`)).join(', ')}.`);
|
|
222
|
+
function checkRequiredRootEntries(state) {
|
|
223
|
+
for (const entry of [...requiredRootEntries(state.resolver)].sort(byCodeUnit)) {
|
|
224
|
+
if (entry === 'apps') continue;
|
|
225
|
+
if (!state.tree.top.includes(entry)) state.finding('HFS_ROOT_ENTRY_MISSING', entry, `The ${state.profile} side requires root entry ${entry}.`);
|
|
264
226
|
}
|
|
265
|
-
if (tree.hasDir('.starciwork') && headings.some(item => item.name === 'Work') &&
|
|
266
|
-
!/\.starciwork\b/u.test(sectionBody('Work')))
|
|
267
|
-
finding('HFS_README_WORK_POINTER_MISSING', 'README.md', 'Work must point at the backend .starciwork tree.');
|
|
268
|
-
let fenced = false;
|
|
269
|
-
lines.forEach((line, index) => {
|
|
270
|
-
if (/^\s*```/u.test(line)) { fenced = !fenced; return; }
|
|
271
|
-
if (fenced) return;
|
|
272
|
-
const prose = line.replace(/`[^`]*`/gu, '');
|
|
273
|
-
for (const match of prose.matchAll(/https?:\/\/[^\s<>)"']+/giu)) {
|
|
274
|
-
const value = match[0].replace(/[.,;!?]+$/u, '');
|
|
275
|
-
try {
|
|
276
|
-
if (privateHost(new URL(value).hostname))
|
|
277
|
-
finding('HFS_README_PRIVATE_URL', 'README.md', `README URL ${value} points at a local or private host.`, index + 1);
|
|
278
|
-
} catch { /* Malformed URLs are outside this presentation rule. */ }
|
|
279
|
-
}
|
|
280
|
-
const badgeTargets = [
|
|
281
|
-
...[...prose.matchAll(/!\[([^\]]*)\]\(([^)]+)\)/gu)].map(match => ({ alt: match[1], target: match[2] })),
|
|
282
|
-
...[...prose.matchAll(/<img\b[^>]*>/giu)].map(match => ({
|
|
283
|
-
alt: /\balt=["']([^"']*)["']/iu.exec(match[0])?.[1] ?? '',
|
|
284
|
-
target: /\bsrc=["']([^"']*)["']/iu.exec(match[0])?.[1] ?? '',
|
|
285
|
-
})),
|
|
286
|
-
];
|
|
287
|
-
for (const { alt, target: rawTarget } of badgeTargets) {
|
|
288
|
-
const target = rawTarget.trim().replace(/^<|>$/gu, '');
|
|
289
|
-
if (!/badge/iu.test(alt) && !/badge|shields\.io|badgen\.net/iu.test(target)) continue;
|
|
290
|
-
try {
|
|
291
|
-
const url = new URL(target);
|
|
292
|
-
if (url.protocol !== 'https:' || privateHost(url.hostname) ||
|
|
293
|
-
/^(?:img\.shields\.io|badgen\.net)$/iu.test(url.hostname) && /^\/badge\//u.test(url.pathname))
|
|
294
|
-
finding('HFS_README_BADGE_NOT_LIVE', 'README.md', `Badge ${target} must represent a live external HTTPS service.`, index + 1);
|
|
295
|
-
} catch { finding('HFS_README_BADGE_NOT_LIVE', 'README.md', `Badge ${target} must use a live external HTTPS service.`, index + 1); }
|
|
296
|
-
}
|
|
297
|
-
});
|
|
298
|
-
return { violations, coverage: { status: 'checked', source: tree.source } };
|
|
299
227
|
}
|
|
300
228
|
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
const
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
229
|
+
function missingBackendFiles(state, app) {
|
|
230
|
+
const { tree, resolver } = state;
|
|
231
|
+
const missing = [];
|
|
232
|
+
// A back-end app must hold exactly what the slot of its kind requires (each kind's slot names its own requires).
|
|
233
|
+
for (const required of resolver.requiredFiles(`apps/${app}/src/main.ts`)) {
|
|
234
|
+
const directory = required.endsWith('/');
|
|
235
|
+
if (!(directory ? tree.hasDir(required.slice(0, -1)) : tree.hasFile(required))) missing.push(required.slice(`apps/${app}/`.length));
|
|
236
|
+
}
|
|
237
|
+
return missing;
|
|
238
|
+
}
|
|
309
239
|
|
|
310
|
-
|
|
240
|
+
function missingFrontendFiles(state, app) {
|
|
241
|
+
const { tree, resolver } = state;
|
|
242
|
+
const missing = [];
|
|
243
|
+
// next-env.d.ts is generated by Next and commonly ignored by Git; it is not
|
|
244
|
+
// a reliable tracked-tree input.
|
|
245
|
+
// A front-end app holds the files its slot requires at its own root (next.config.ts, tsconfig.json, ...; it has no package.json).
|
|
246
|
+
for (const required of resolver.requiredFiles(`apps/${app}/next.config.ts`).filter(entry => !entry.slice(`apps/${app}/`.length).includes('/'))) {
|
|
247
|
+
if (!tree.hasFile(required)) missing.push(required.slice(`apps/${app}/`.length));
|
|
248
|
+
}
|
|
249
|
+
return missing;
|
|
250
|
+
}
|
|
311
251
|
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
252
|
+
function checkApplicationRequiredFiles(state, app) {
|
|
253
|
+
const { tree, backend, frontend, finding } = state;
|
|
254
|
+
if (!tree.hasDir(`apps/${app}`)) {
|
|
255
|
+
finding('HFS_APP_LAYOUT_INVALID', `apps/${app}`, `Application entry apps/${app} must be a directory.`);
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
const missing = [];
|
|
259
|
+
if (!tree.hasDir(`apps/${app}/src`)) missing.push('src/');
|
|
260
|
+
if (backend) missing.push(...missingBackendFiles(state, app));
|
|
261
|
+
if (frontend) missing.push(...missingFrontendFiles(state, app));
|
|
262
|
+
if (missing.length) finding('HFS_APP_LAYOUT_INVALID', `apps/${app}`, `Application apps/${app} lacks ${missing.join(', ')} required by the HFS app layout.`);
|
|
263
|
+
}
|
|
316
264
|
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
const
|
|
323
|
-
|
|
324
|
-
finding('HFS_HOOKS_PATH_REDIRECTED', '.git/config', `core.hooksPath is set to ${value} in this clone. Unset it (git config --local --unset core.hooksPath) and let husky own the hooks; a redirected path skips the pre-commit and pre-push gates.`);
|
|
325
|
-
return { status: 'checked' };
|
|
265
|
+
function checkApplications(state) {
|
|
266
|
+
const { tree, finding } = state;
|
|
267
|
+
const apps = tree.children('apps').sort(byCodeUnit);
|
|
268
|
+
if (!tree.hasDir('apps') || apps.length === 0)
|
|
269
|
+
finding('HFS_APPS_REQUIRED', 'apps', 'Every HFS repository is an apps/<app>/ monorepo; apps/ must hold at least one application.');
|
|
270
|
+
for (const app of apps) checkApplicationRequiredFiles(state, app);
|
|
271
|
+
return apps;
|
|
326
272
|
}
|
|
327
273
|
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
let pkg = null;
|
|
335
|
-
try { pkg = JSON.parse(readText(root, 'package.json') ?? ''); } catch { /* the package checks own invalid JSON */ }
|
|
336
|
-
const scripts = pkg?.scripts ?? {};
|
|
337
|
-
// 1. Husky hooks and the scripts they call (transitively through `npm run <script>`).
|
|
338
|
-
const pending = [];
|
|
339
|
-
for (const hook of ['.husky/pre-commit', '.husky/pre-push']) {
|
|
340
|
-
const text = readText(root, hook);
|
|
341
|
-
if (text === null) continue;
|
|
342
|
-
const body = withoutComments(text);
|
|
343
|
-
if (runsE2e(body)) finding(rule, hook, `${hook} runs integration, e2e or contract. They are manual only; hooks run unit, lint and typecheck.`);
|
|
344
|
-
for (const match of body.matchAll(/npm\s+run\s+([\w:.-]+)/gu)) pending.push(match[1]);
|
|
274
|
+
function checkBackendSourceLayout(state) {
|
|
275
|
+
if (!state.backend) return;
|
|
276
|
+
const { tree, resolver, finding } = state;
|
|
277
|
+
for (const child of tree.children('src').sort(byCodeUnit)) {
|
|
278
|
+
if (!BACKEND_SRC_CHILDREN.has(child))
|
|
279
|
+
finding('HFS_SRC_LAYOUT_INVALID', `src/${child}`, `Backend src/ holds only features/, modules/ and tests/; ${child} must move to its owner.`);
|
|
345
280
|
}
|
|
346
|
-
const
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
for (const name of pending) {
|
|
350
|
-
if (called.has(name)) continue;
|
|
351
|
-
called.add(name);
|
|
352
|
-
const command = scripts[name];
|
|
353
|
-
if (typeof command !== 'string') continue;
|
|
354
|
-
if (runsE2e(command)) finding(rule, 'package.json', `Script ${name} is run by a husky hook and touches integration, e2e or contract. They are manual only.`);
|
|
355
|
-
for (const match of command.matchAll(/npm\s+run\s+([\w:.-]+)/gu)) pending.push(match[1]);
|
|
281
|
+
for (const tier of tree.children('src/modules').sort(byCodeUnit)) {
|
|
282
|
+
if (!moduleTiersOf(resolver).has(tier))
|
|
283
|
+
finding('HFS_MODULE_TIER_INVALID', `src/modules/${tier}`, `Module tier ${tier} is not one of ${[...moduleTiersOf(resolver)].sort(byCodeUnit).join(', ')}.`);
|
|
356
284
|
}
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
finding(rule, 'package.json', `Script ${name} runs jest without --selectProjects unit and would run the integration, e2e or contract project.`);
|
|
363
|
-
}
|
|
364
|
-
const jestText = readText(root, jestConfig) ?? '';
|
|
365
|
-
if (/collectCoverageFrom/u.test(jestText) && !/!src\/tests\/(?:\*\*|e2e)/u.test(jestText))
|
|
366
|
-
finding(rule, jestConfig, 'collectCoverageFrom must exclude src/tests/** so no integration, e2e or contract file counts toward coverage.');
|
|
367
|
-
}
|
|
368
|
-
// 3. A workflow that starts on push or pull_request never runs e2e.
|
|
369
|
-
for (const file of tree.files().filter(entry => /^\.github\/workflows\/[^/]+\.ya?ml$/u.test(entry))) {
|
|
370
|
-
const text = readText(root, file);
|
|
371
|
-
if (text === null) continue;
|
|
372
|
-
const trigger = /^on:.*(?:\n(?:[ \t]+.*|)$)*/mu.exec(text)?.[0] ?? '';
|
|
373
|
-
if (!/\b(?:push|pull_request)\b/u.test(trigger)) continue;
|
|
374
|
-
if (runsE2e(withoutComments(text)))
|
|
375
|
-
finding(rule, file, `${file} runs e2e on push or pull_request. Move the e2e job to its own workflow with on: workflow_dispatch only.`);
|
|
285
|
+
const testChildren = slotTestChildren(resolver);
|
|
286
|
+
for (const child of tree.children('src/tests').sort(byCodeUnit)) {
|
|
287
|
+
// A file directly below src/tests/ that a slot owns (src/tests/tsconfig.json, be.tool-config) is that slot's, not a stray folder.
|
|
288
|
+
if (!testChildren.has(child) && resolver.classifyPath(`src/tests/${child}`).status !== 'owned')
|
|
289
|
+
finding('HFS_SRC_LAYOUT_INVALID', `src/tests/${child}`, `Backend src/tests/ holds only ${[...testChildren].join(', ')} and the files a slot owns there; ${child} must move.`);
|
|
376
290
|
}
|
|
377
291
|
}
|
|
378
292
|
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
const
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
const files = tree.files();
|
|
390
|
-
if (files.some(file => /^src\/tests\/(?:world|integration|e2e|contract)\/.+\.[cm]?tsx?$/u.test(file)) && !excludesTestTrees &&
|
|
391
|
-
(defaultAll || JSON.stringify(tsconfig.include ?? []).includes('src')))
|
|
392
|
-
finding(rule, 'tsconfig.json', 'The default tsconfig includes src/tests/{world,integration,e2e,contract}/**. Exclude those trees and check them with src/tests/tsconfig.json (typecheck:tests).');
|
|
293
|
+
function checkTestTopology(state) {
|
|
294
|
+
// Test kinds and folders that do not exist: int-spec, harness-spec, src/tests/harness and live/;
|
|
295
|
+
// a backend spec sits by its kind (beside its subject, or under src/tests/{integration,e2e,contract}/).
|
|
296
|
+
for (const file of state.tree.files()) {
|
|
297
|
+
if (UNSUPPORTED_TEST_SUFFIX.test(file))
|
|
298
|
+
state.finding('BE_TEST_TOPOLOGY', file, `${file} uses an unsupported test kind. Only unit *.spec.ts, *.integration-spec.ts, *.e2e-spec.ts and *.contract-spec.ts exist, each in its own folder under src/tests/.`);
|
|
299
|
+
else if (state.backend && UNSUPPORTED_TEST_FOLDER.test(file))
|
|
300
|
+
state.finding('BE_TEST_TOPOLOGY', file, `${file} sits in an unsupported test folder. Unit specs sit beside their subject, flows go under src/tests/e2e/<area>/ and test infrastructure under src/tests/world/.`);
|
|
301
|
+
else if (state.backend && file.startsWith('src/tests/') && isExtraTestConfig(file))
|
|
302
|
+
state.finding('BE_TEST_TOPOLOGY', file, `${file} is a per-lane test config. One root jest.config.js declares exactly the unit, integration, e2e and contract projects.`);
|
|
393
303
|
}
|
|
394
304
|
}
|
|
395
305
|
|
|
@@ -403,94 +313,16 @@ export function checkHfs(config) {
|
|
|
403
313
|
const finding = (ruleId, entry, message) => violations.push({ ruleId, path: entry, line: 1, column: 1, message });
|
|
404
314
|
const profile = backend ? 'be' : 'fe';
|
|
405
315
|
const resolver = resolverOf(config.root, profile, config.hfs);
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
if (frontend && !backend) {
|
|
413
|
-
if (entry === '.starciwork') { finding('HFS_WORK_IN_FE', entry, 'The fe side must not hold a .starciwork tree; Work records live in the app root .starciwork.'); continue; }
|
|
414
|
-
if (entry === 'src') { finding('HFS_ROOT_SRC_FORBIDDEN_FE', entry, 'The fe side keeps source only under apps/<app>/src; the fe/src/ tree must move.'); continue; }
|
|
415
|
-
}
|
|
416
|
-
if (frontend && !backend && (isFeTestPath(entry) || isFeTestPath(`${entry}/x`))) continue; // a test entry of a front end is FE_NO_TESTS's, the one finding of that path
|
|
417
|
-
if (!allowed.has(entry)) {
|
|
418
|
-
finding('HFS_ROOT_ENTRY_FORBIDDEN', entry, `Root entry ${entry} of the ${profile} side is not in the slots of the ${profile} side.`);
|
|
419
|
-
}
|
|
420
|
-
}
|
|
421
|
-
|
|
422
|
-
for (const entry of [...requiredRootEntries(resolver)].sort()) {
|
|
423
|
-
if (entry === 'apps') continue;
|
|
424
|
-
if (!tree.top.includes(entry)) finding('HFS_ROOT_ENTRY_MISSING', entry, `The ${profile} side requires root entry ${entry}.`);
|
|
425
|
-
}
|
|
426
|
-
|
|
427
|
-
const apps = tree.children('apps').sort();
|
|
428
|
-
if (!tree.hasDir('apps') || apps.length === 0) {
|
|
429
|
-
finding('HFS_APPS_REQUIRED', 'apps', 'Every HFS repository is an apps/<app>/ monorepo; apps/ must hold at least one application.');
|
|
430
|
-
}
|
|
431
|
-
for (const app of apps) {
|
|
432
|
-
const missing = [];
|
|
433
|
-
if (!tree.hasDir(`apps/${app}`)) {
|
|
434
|
-
finding('HFS_APP_LAYOUT_INVALID', `apps/${app}`, `Application entry apps/${app} must be a directory.`);
|
|
435
|
-
continue;
|
|
436
|
-
}
|
|
437
|
-
if (!tree.hasDir(`apps/${app}/src`)) missing.push('src/');
|
|
438
|
-
// A back-end app must hold exactly what the slot of its kind requires (each kind's slot names its own requires).
|
|
439
|
-
if (backend) for (const required of resolver.requiredFiles(`apps/${app}/src/main.ts`)) {
|
|
440
|
-
const directory = required.endsWith('/');
|
|
441
|
-
if (!(directory ? tree.hasDir(required.slice(0, -1)) : tree.hasFile(required))) missing.push(required.slice(`apps/${app}/`.length));
|
|
442
|
-
}
|
|
443
|
-
// next-env.d.ts is generated by Next and commonly ignored by Git; it is not
|
|
444
|
-
// a reliable tracked-tree input.
|
|
445
|
-
// A front-end app holds the files its slot requires at its own root (next.config.ts, tsconfig.json, ...; it has no package.json).
|
|
446
|
-
if (frontend) for (const required of resolver.requiredFiles(`apps/${app}/next.config.ts`).filter(entry => !entry.slice(`apps/${app}/`.length).includes('/'))) {
|
|
447
|
-
if (!tree.hasFile(required)) missing.push(required.slice(`apps/${app}/`.length));
|
|
448
|
-
}
|
|
449
|
-
if (missing.length) finding('HFS_APP_LAYOUT_INVALID', `apps/${app}`, `Application apps/${app} lacks ${missing.join(', ')} required by the HFS app layout.`);
|
|
450
|
-
}
|
|
451
|
-
|
|
452
|
-
if (backend) {
|
|
453
|
-
for (const child of tree.children('src').sort()) {
|
|
454
|
-
if (!BACKEND_SRC_CHILDREN.has(child)) {
|
|
455
|
-
finding('HFS_SRC_LAYOUT_INVALID', `src/${child}`, `Backend src/ holds only features/, modules/ and tests/; ${child} must move to its owner.`);
|
|
456
|
-
}
|
|
457
|
-
}
|
|
458
|
-
for (const tier of tree.children('src/modules').sort()) {
|
|
459
|
-
if (!moduleTiersOf(resolver).has(tier)) {
|
|
460
|
-
finding('HFS_MODULE_TIER_INVALID', `src/modules/${tier}`, `Module tier ${tier} is not one of ${[...moduleTiersOf(resolver)].sort().join(', ')}.`);
|
|
461
|
-
}
|
|
462
|
-
}
|
|
463
|
-
const testChildren = slotTestChildren(resolver);
|
|
464
|
-
for (const child of tree.children('src/tests').sort()) {
|
|
465
|
-
// A file directly below src/tests/ that a slot owns (src/tests/tsconfig.json, be.tool-config) is that slot's, not a stray folder.
|
|
466
|
-
if (!testChildren.has(child) && resolver.classifyPath(`src/tests/${child}`).status !== 'owned') {
|
|
467
|
-
finding('HFS_SRC_LAYOUT_INVALID', `src/tests/${child}`, `Backend src/tests/ holds only ${[...testChildren].join(', ')} and the files a slot owns there; ${child} must move.`);
|
|
468
|
-
}
|
|
469
|
-
}
|
|
470
|
-
}
|
|
471
|
-
|
|
472
|
-
// Retired test kinds and folders: int-spec and harness-spec are gone, so are src/tests/harness and
|
|
473
|
-
// live/; a backend spec sits by its kind (beside its subject, or under src/tests/{integration,e2e,contract}/).
|
|
474
|
-
for (const file of tree.files()) {
|
|
475
|
-
if (RETIRED_TEST_SUFFIX.test(file))
|
|
476
|
-
finding('BE_TEST_TOPOLOGY', file, `${file} uses a retired test kind. Only unit *.spec.ts, *.integration-spec.ts, *.e2e-spec.ts and *.contract-spec.ts exist, each in its own folder under src/tests/.`);
|
|
477
|
-
else if (backend && RETIRED_TEST_FOLDER.test(file))
|
|
478
|
-
finding('BE_TEST_TOPOLOGY', file, `${file} sits in a retired test folder. Unit specs sit beside their subject, flows go under src/tests/e2e/<area>/ and test infrastructure under src/tests/world/.`);
|
|
479
|
-
else if (backend && /^src\/tests\//u.test(file) && EXTRA_TEST_CONFIG.test(file))
|
|
480
|
-
finding('BE_TEST_TOPOLOGY', file, `${file} is a per-lane test config. One root jest.config.js declares exactly the unit, integration, e2e and contract projects.`);
|
|
481
|
-
}
|
|
482
|
-
|
|
316
|
+
const state = { config, tree, violations, finding, backend, frontend, profile, resolver, allowed: slotRootEntries(resolver, profile) };
|
|
317
|
+
checkSideRootEntries(state);
|
|
318
|
+
checkRequiredRootEntries(state);
|
|
319
|
+
const apps = checkApplications(state);
|
|
320
|
+
checkBackendSourceLayout(state);
|
|
321
|
+
checkTestTopology(state);
|
|
483
322
|
if (backend) testTreesOutOfDefaultProgram({ root: config.root, tree, finding });
|
|
484
|
-
|
|
485
323
|
return {
|
|
486
324
|
violations,
|
|
487
|
-
coverage: {
|
|
488
|
-
status: 'checked',
|
|
489
|
-
source: tree.source,
|
|
490
|
-
rootEntries: tree.top.length,
|
|
491
|
-
apps,
|
|
492
|
-
ruleIds: [...HFS_RULE_IDS],
|
|
493
|
-
},
|
|
325
|
+
coverage: { status: 'checked', source: tree.source, rootEntries: tree.top.length, apps, ruleIds: [...HFS_RULE_IDS] },
|
|
494
326
|
};
|
|
495
327
|
}
|
|
496
328
|
|
|
@@ -504,11 +336,11 @@ export function checkAppRoot({ root, resolver, tree = treeView(root) }) {
|
|
|
504
336
|
const finding = (ruleId, entry, message) => violations.push({ ruleId, path: entry, line: 1, column: 1, message });
|
|
505
337
|
violations.push(...checkRepoPresentation({ root, tree, profile: 'app', edition: resolver.repo?.edition ?? 'full' }).violations);
|
|
506
338
|
const allowed = slotRootEntries(resolver, 'app');
|
|
507
|
-
for (const entry of [...tree.top].sort()) {
|
|
339
|
+
for (const entry of [...tree.top].sort(byCodeUnit)) {
|
|
508
340
|
if (NON_NPM_ENTRIES.has(entry) || /\.md$/iu.test(entry)) continue;
|
|
509
341
|
if (!allowed.has(entry)) finding('HFS_ROOT_ENTRY_FORBIDDEN', entry, `Root entry ${entry} is not in the slots of the app root.`);
|
|
510
342
|
}
|
|
511
|
-
for (const entry of [...requiredRootEntries(resolver)].sort()) {
|
|
343
|
+
for (const entry of [...requiredRootEntries(resolver)].sort(byCodeUnit)) {
|
|
512
344
|
if (entry === 'README.md' || entry === '.gitattributes') continue; // checkRepoPresentation reports these two
|
|
513
345
|
if (!tree.top.includes(entry)) finding('HFS_ROOT_ENTRY_MISSING', entry, `The app root requires root entry ${entry}.`);
|
|
514
346
|
}
|