@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
|
@@ -31,6 +31,10 @@ function ownerOf(owners, file) {
|
|
|
31
31
|
return owners.find(owner => isInside(owner.root, file)) ?? null;
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
function reexportSteps(context, current) {
|
|
35
|
+
return (context.edges.get(current.file) ?? []).filter(candidate => candidate.reexport).map(candidate => ({ file: candidate.to, chain: [...current.chain, candidate.to] }));
|
|
36
|
+
}
|
|
37
|
+
|
|
34
38
|
function privateOwnerChain(context, owners, actionEntries, edge) {
|
|
35
39
|
const sourceOwner = ownerOf(owners, edge.from);
|
|
36
40
|
const queue = [{ file: edge.to, chain: [edge.from, edge.to] }];
|
|
@@ -41,9 +45,7 @@ function privateOwnerChain(context, owners, actionEntries, edge) {
|
|
|
41
45
|
visited.add(current.file);
|
|
42
46
|
const owner = ownerOf(owners, current.file);
|
|
43
47
|
if (owner && sourceOwner?.id !== owner.id) return owner.entries.has(current.file) || actionEntries.has(current.file) ? null : { owner, chain: current.chain };
|
|
44
|
-
|
|
45
|
-
queue.push({ file: candidate.to, chain: [...current.chain, candidate.to] });
|
|
46
|
-
}
|
|
48
|
+
queue.push(...reexportSteps(context, current));
|
|
47
49
|
}
|
|
48
50
|
return null;
|
|
49
51
|
}
|
|
@@ -59,15 +61,8 @@ function arrangesSchema(config, from, to) {
|
|
|
59
61
|
&& resolver.classifyPath(relativePath(config.root, to)).slot === 'be.persistence';
|
|
60
62
|
}
|
|
61
63
|
|
|
62
|
-
|
|
63
|
-
export function checkOwners(config, context) {
|
|
64
|
-
if (!config.owners?.length) return [];
|
|
65
|
-
const owners = ownerDeclarations(config, context);
|
|
64
|
+
function publicEntryViolations(config, context, owners, sourceFiles, actionEntries) {
|
|
66
65
|
const violations = [];
|
|
67
|
-
const sourceFiles = new Map(context.files.map(file => [canonical(file.fileName), file]));
|
|
68
|
-
const actionEntries = new Set(config.kinds.includes('frontend')
|
|
69
|
-
? [...sourceFiles].filter(([, file]) => isServerActionModule(context.ts, file)).map(([name]) => name)
|
|
70
|
-
: []);
|
|
71
66
|
for (const owner of owners) {
|
|
72
67
|
const sourceFile = sourceFiles.get(owner.entry);
|
|
73
68
|
if (!sourceFile) {
|
|
@@ -87,11 +82,16 @@ export function checkOwners(config, context) {
|
|
|
87
82
|
path: relativePath(config.root, canonical(entryFile.fileName)),
|
|
88
83
|
...sourceLocation(entryFile, statement),
|
|
89
84
|
owner: owner.id,
|
|
90
|
-
message: `Owner ${owner.id} public entry must use explicit named exports rather than export
|
|
85
|
+
message: `Owner ${owner.id} public entry must use explicit named exports rather than export *.`,
|
|
91
86
|
});
|
|
92
87
|
}
|
|
93
88
|
}
|
|
94
89
|
}
|
|
90
|
+
return violations;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function ownerImportViolations(config, context, owners, actionEntries) {
|
|
94
|
+
const violations = [];
|
|
95
95
|
for (const sourceFile of context.files) {
|
|
96
96
|
const from = canonical(sourceFile.fileName);
|
|
97
97
|
for (const edge of context.edges.get(from) ?? []) {
|
|
@@ -107,9 +107,23 @@ export function checkOwners(config, context) {
|
|
|
107
107
|
specifier: edge.specifier,
|
|
108
108
|
resolvedPath: relativePath(config.root, bypass.chain.at(-1)),
|
|
109
109
|
dependencyChain: bypass.chain.map(file => relativePath(config.root, file)),
|
|
110
|
-
message: `Cross-owner dependency must enter ${bypass.owner.id} through ${relativePath(config.root, bypass.owner.entry)}
|
|
110
|
+
message: `Cross-owner dependency must enter ${bypass.owner.id} through ${relativePath(config.root, bypass.owner.entry)}.`,
|
|
111
111
|
});
|
|
112
112
|
}
|
|
113
113
|
}
|
|
114
114
|
return violations;
|
|
115
115
|
}
|
|
116
|
+
|
|
117
|
+
/** Enforce explicit same-source owner entries without constraining imports inside one owner. */
|
|
118
|
+
export function checkOwners(config, context) {
|
|
119
|
+
if (!config.owners?.length) return [];
|
|
120
|
+
const owners = ownerDeclarations(config, context);
|
|
121
|
+
const sourceFiles = new Map(context.files.map(file => [canonical(file.fileName), file]));
|
|
122
|
+
const actionEntries = new Set(config.kinds.includes('frontend')
|
|
123
|
+
? [...sourceFiles].filter(([, file]) => isServerActionModule(context.ts, file)).map(([name]) => name)
|
|
124
|
+
: []);
|
|
125
|
+
return [
|
|
126
|
+
...publicEntryViolations(config, context, owners, sourceFiles, actionEntries),
|
|
127
|
+
...ownerImportViolations(config, context, owners, actionEntries),
|
|
128
|
+
];
|
|
129
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { treeOf } from './required-files.mjs';
|
|
4
|
+
import { byCodeUnit } from '../../lib/list.mjs';
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* R63 `package-shape` (FE_PACKAGE_SHAPE, knowledge/hfs/README.md 6.2). A shared package is built to `dist` and says what it
|
|
@@ -18,6 +19,7 @@ import { treeOf } from './required-files.mjs';
|
|
|
18
19
|
export const PACKAGE_SHAPE_RULE_IDS = ['FE_PACKAGE_SHAPE'];
|
|
19
20
|
|
|
20
21
|
const RULE = 'FE_PACKAGE_SHAPE';
|
|
22
|
+
const UNREADABLE = Symbol('unreadable');
|
|
21
23
|
|
|
22
24
|
/** Every string target of an exports value (a string, a condition map, an array of either), with the condition path. */
|
|
23
25
|
function targetsOf(value, trail = []) {
|
|
@@ -29,35 +31,61 @@ function targetsOf(value, trail = []) {
|
|
|
29
31
|
|
|
30
32
|
const insideDist = target => path.posix.normalize(target).replace(/^\.\//u, '').startsWith('dist/');
|
|
31
33
|
|
|
32
|
-
|
|
33
|
-
const resolver = graph.resolver;
|
|
34
|
-
const violations = [];
|
|
35
|
-
let packages = 0;
|
|
34
|
+
function packageRoots(config, resolver) {
|
|
36
35
|
const roots = new Set();
|
|
37
36
|
for (const rel of treeOf(config.root).files) {
|
|
38
37
|
const owner = resolver.ownerOf(rel);
|
|
39
38
|
if (owner && resolver.slot(owner.slot)?.tier === 'package') roots.add(owner.root);
|
|
40
39
|
}
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
for (const
|
|
59
|
-
if (
|
|
40
|
+
return [...roots].sort(byCodeUnit);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function readManifest(config, manifestRel) {
|
|
44
|
+
try {
|
|
45
|
+
return JSON.parse(fs.readFileSync(path.join(config.root, ...manifestRel.split('/')), 'utf8'));
|
|
46
|
+
} catch {
|
|
47
|
+
return UNREADABLE;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function reportExportIssues(manifest, manifestRel, report) {
|
|
52
|
+
if (manifest.exports === undefined) {
|
|
53
|
+
report(`${manifestRel} declares no exports map; a package lists what it exports, subpath by subpath, each pointing into ./dist/.`);
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
if (typeof manifest.exports === 'object' && !Array.isArray(manifest.exports)) {
|
|
57
|
+
for (const key of Object.keys(manifest.exports)) {
|
|
58
|
+
if (key.includes('*') || key.endsWith('/')) report(`exports key "${key}" of ${manifestRel} is a pattern or folder mapping; list each exported subpath explicitly.`, { export: key });
|
|
60
59
|
}
|
|
61
60
|
}
|
|
61
|
+
for (const { target, trail } of targetsOf(manifest.exports)) {
|
|
62
|
+
if (!insideDist(target)) report(`exports target "${target}" (${trail.join(' > ') || '.'}) of ${manifestRel} is not inside ./dist/; a package exports what it built, never its source.`, { export: target });
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function reportManifestIssues(manifest, manifestRel, report) {
|
|
67
|
+
if (typeof manifest.scripts?.build !== 'string' || !manifest.scripts.build.trim()) report(`${manifestRel} has no scripts.build; a shared package is built to dist before an app consumes it.`);
|
|
68
|
+
reportExportIssues(manifest, manifestRel, report);
|
|
69
|
+
for (const field of ['main', 'module', 'types', 'typings']) {
|
|
70
|
+
if (typeof manifest[field] === 'string' && !insideDist(manifest[field])) report(`${field} "${manifest[field]}" of ${manifestRel} is not inside ./dist/; a package points at what it built.`, { field });
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function inspectPackage(config, root, violations) {
|
|
75
|
+
const manifestRel = `${root}/package.json`;
|
|
76
|
+
const report = (message, extra = {}) => violations.push({ ruleId: RULE, path: manifestRel, line: 1, column: 1, package: root, message, ...extra });
|
|
77
|
+
const manifest = readManifest(config, manifestRel);
|
|
78
|
+
if (manifest === UNREADABLE) return false;
|
|
79
|
+
reportManifestIssues(manifest, manifestRel, report);
|
|
80
|
+
return true;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function checkPackageShape({ config, graph }) {
|
|
84
|
+
const roots = packageRoots(config, graph.resolver);
|
|
85
|
+
const violations = [];
|
|
86
|
+
let packages = 0;
|
|
87
|
+
for (const root of roots) {
|
|
88
|
+
if (inspectPackage(config, root, violations)) packages += 1;
|
|
89
|
+
}
|
|
62
90
|
return { violations, coverage: { status: 'checked', packages } };
|
|
63
91
|
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { repositoryName } from '../repo-identity.mjs';
|
|
4
|
+
import { managedScriptNames } from './managed-scripts.mjs';
|
|
5
|
+
import { trimTrailingChars } from '../linear-text.mjs';
|
|
6
|
+
|
|
7
|
+
const RUNTIME_ROOT_MARKDOWN = new Set(['README.md', 'CONTEXT.md', 'CONTRIBUTING.md', 'CHANGELOG.md', 'THIRD_PARTY_NOTICES.md']);
|
|
8
|
+
const PRODUCT_ROOT_MARKDOWN = new Set(['README.md']);
|
|
9
|
+
const README_SECTIONS = ['Overview', 'Stack', 'Repository layout', 'Development'];
|
|
10
|
+
const DEVELOPMENT_SCRIPTS = ['typecheck', 'lint', 'build', 'test'];
|
|
11
|
+
const README_HEADING = new RegExp(['^## ', '(.+?)', String.raw`\s*$`].join(''), 'u');
|
|
12
|
+
const README_URL = new RegExp(['https?:', String.raw`\/\/`, String.raw`[^\s<>)"']+`].join(''), 'giu');
|
|
13
|
+
|
|
14
|
+
function privateHost(hostname) {
|
|
15
|
+
const host = hostname.toLowerCase().replace(/^\[|\]$/g, '').replace(/\.$/, '');
|
|
16
|
+
if (host === 'localhost' || host === '::1' || host === '0.0.0.0' ||
|
|
17
|
+
/(?:\.localhost|\.local|\.internal|\.lan)$/u.test(host)) return true;
|
|
18
|
+
if (host.includes(':')) return /^(?:::|f[cd][0-9a-f]*:|fe[89ab][0-9a-f]*:)/iu.test(host);
|
|
19
|
+
if (!host.includes('.')) return true;
|
|
20
|
+
const octets = host.split('.');
|
|
21
|
+
if (octets.length !== 4 || !octets.every(part => /^\d{1,3}$/u.test(part) && Number(part) <= 255)) return false;
|
|
22
|
+
const [a, b] = octets.map(Number);
|
|
23
|
+
return a === 0 || a === 10 || a === 127 || a === 169 && b === 254 ||
|
|
24
|
+
a === 172 && b >= 16 && b <= 31 || a === 192 && b === 168 || a === 100 && b >= 64 && b <= 127;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function scriptCommand(name) {
|
|
28
|
+
const escaped = name.replace(/[.*+?^${}()|[\]\\]/gu, String.raw`\$&`);
|
|
29
|
+
return new RegExp(name === 'test' ? String.raw`npm (?:run test|test)(?![\w:-])` : String.raw`npm run ${escaped}(?![\w:-])`, 'u');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function addFinding(state, ruleId, entry, message, line = 1) {
|
|
33
|
+
state.violations.push({ ruleId, path: entry, line, column: 1, message });
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function checkRootPackageManager(state) {
|
|
37
|
+
try {
|
|
38
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(state.root, 'package.json'), 'utf8'));
|
|
39
|
+
if (pkg.packageManager && !/^npm@\d/u.test(pkg.packageManager))
|
|
40
|
+
addFinding(state, 'HFS_PACKAGE_MANAGER_MIXED', 'package.json', `packageManager ${pkg.packageManager} contradicts the npm package-lock.json contract.`);
|
|
41
|
+
} catch { /* The repository's package/config checks own unreadable or invalid JSON. */ }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function checkRootPresentation(state) {
|
|
45
|
+
const { runtime, tree } = state;
|
|
46
|
+
for (const entry of tree.top) {
|
|
47
|
+
if (/\.md$/iu.test(entry) && !(runtime ? RUNTIME_ROOT_MARKDOWN : PRODUCT_ROOT_MARKDOWN).has(entry))
|
|
48
|
+
addFinding(state, 'HFS_ROOT_MARKDOWN_FORBIDDEN', entry, `Root Markdown ${entry} belongs under docs/ or the owning Work record.`);
|
|
49
|
+
if (state.nonNpmEntries.has(entry)) addFinding(state, 'HFS_PACKAGE_MANAGER_MIXED', entry, `${entry} contradicts the npm package manager contract.`);
|
|
50
|
+
}
|
|
51
|
+
for (const entry of ['.gitattributes', 'README.md']) {
|
|
52
|
+
if (!tree.hasFile(entry)) addFinding(state, 'HFS_ROOT_ENTRY_MISSING', entry, `Repository presentation requires root ${entry}.`);
|
|
53
|
+
}
|
|
54
|
+
if (tree.hasFile('package.json')) checkRootPackageManager(state);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function readmeSectionBody(lines, headings, section) {
|
|
58
|
+
const start = headings.find(item => item.name === section)?.index;
|
|
59
|
+
if (start === undefined) return '';
|
|
60
|
+
const end = headings.find(item => item.index > start)?.index ?? lines.length;
|
|
61
|
+
return lines.slice(start + 1, end).join('\n');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function checkReadmeHeadings(state, lines) {
|
|
65
|
+
const { root, runtime, tree } = state;
|
|
66
|
+
const name = runtime ? 'StarCi' : repositoryName(root);
|
|
67
|
+
if (lines[0].trim().toLowerCase() !== `# ${name}`.toLowerCase())
|
|
68
|
+
addFinding(state, 'HFS_README_TITLE_INVALID', 'README.md', `README.md must start with # ${name}.`);
|
|
69
|
+
const description = lines.slice(1).find(line => line.trim());
|
|
70
|
+
if (!description || /^\s*(?:#|!\[|\[!\[)/u.test(description) || description.trim().length > 240)
|
|
71
|
+
addFinding(state, 'HFS_README_DESCRIPTION_INVALID', 'README.md', 'Place one concise description line directly below the repository name.');
|
|
72
|
+
const headings = lines.map((line, index) => ({ name: README_HEADING.exec(line)?.[1], index })).filter(item => item.name);
|
|
73
|
+
const required = [...README_SECTIONS, ...(tree.hasDir('.starciwork') ? ['Work'] : [])];
|
|
74
|
+
let previous = -1;
|
|
75
|
+
for (const section of required) {
|
|
76
|
+
const found = headings.find(item => item.name === section);
|
|
77
|
+
if (!found) addFinding(state, 'HFS_README_SECTION_MISSING', 'README.md', `README.md requires a ## ${section} section.`);
|
|
78
|
+
else if (found.index <= previous) addFinding(state, 'HFS_README_SECTION_ORDER', 'README.md', `## ${section} must follow the preceding standard section.`, found.index + 1);
|
|
79
|
+
else previous = found.index;
|
|
80
|
+
}
|
|
81
|
+
return headings;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function checkReadmeDevelopment(state, lines, headings) {
|
|
85
|
+
if (state.runtime || !headings.some(item => item.name === 'Development')) return;
|
|
86
|
+
const development = readmeSectionBody(lines, headings, 'Development');
|
|
87
|
+
const scripts = DEVELOPMENT_SCRIPTS.filter(name => managedScriptNames(state.profile, state.edition).has(name));
|
|
88
|
+
const commands = [/npm (?:ci|install)/u, ...scripts.map(scriptCommand)];
|
|
89
|
+
if (commands.some(command => !command.test(development))) {
|
|
90
|
+
addFinding(state, 'HFS_README_DEVELOPMENT_INCOMPLETE', 'README.md', 'Development must show npm install and the managed script commands: '
|
|
91
|
+
+ scripts.map(name => (name === 'test' ? 'npm test' : `npm run ${name}`)).join(', ') + '.');
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function checkReadmeWorkPointer(state, lines, headings) {
|
|
96
|
+
if (state.tree.hasDir('.starciwork') && headings.some(item => item.name === 'Work') &&
|
|
97
|
+
!/\.starciwork\b/u.test(readmeSectionBody(lines, headings, 'Work')))
|
|
98
|
+
addFinding(state, 'HFS_README_WORK_POINTER_MISSING', 'README.md', 'Work must point at the backend .starciwork tree.');
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function inspectReadmeUrl(state, value, line) {
|
|
102
|
+
try {
|
|
103
|
+
if (privateHost(new URL(value).hostname))
|
|
104
|
+
addFinding(state, 'HFS_README_PRIVATE_URL', 'README.md', `README URL ${value} points at a local or private host.`, line);
|
|
105
|
+
} catch { /* Malformed URLs are outside this presentation rule. */ }
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function badgeTargets(prose) {
|
|
109
|
+
return [
|
|
110
|
+
...[...prose.matchAll(/!\[([^\]]*)\]\(([^)]+)\)/gu)].map(match => ({ alt: match[1], target: match[2] })),
|
|
111
|
+
...[...prose.matchAll(/<img\b[^>]*>/giu)].map(match => ({
|
|
112
|
+
alt: /\balt=["']([^"']*)["']/iu.exec(match[0])?.[1] ?? '',
|
|
113
|
+
target: /\bsrc=["']([^"']*)["']/iu.exec(match[0])?.[1] ?? '',
|
|
114
|
+
})),
|
|
115
|
+
];
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function inspectBadgeTarget(state, alt, rawTarget, line) {
|
|
119
|
+
const target = rawTarget.trim().replace(/^<|>$/gu, '');
|
|
120
|
+
if (!/badge/iu.test(alt) && !/badge|shields\.io|badgen\.net/iu.test(target)) return;
|
|
121
|
+
try {
|
|
122
|
+
const url = new URL(target);
|
|
123
|
+
if (url.protocol !== 'https:' || privateHost(url.hostname) ||
|
|
124
|
+
/^(?:img\.shields\.io|badgen\.net)$/iu.test(url.hostname) && url.pathname.startsWith('/badge/'))
|
|
125
|
+
addFinding(state, 'HFS_README_BADGE_NOT_LIVE', 'README.md', `Badge ${target} must represent a live external HTTPS service.`, line);
|
|
126
|
+
} catch { addFinding(state, 'HFS_README_BADGE_NOT_LIVE', 'README.md', `Badge ${target} must use a live external HTTPS service.`, line); }
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function inspectReadmeLine(state, line, index, scan) {
|
|
130
|
+
if (/^\s*```/u.test(line)) {
|
|
131
|
+
scan.fenced = !scan.fenced;
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
if (scan.fenced) return;
|
|
135
|
+
const prose = line.replace(/`[^`]*`/gu, '');
|
|
136
|
+
for (const match of prose.matchAll(README_URL)) inspectReadmeUrl(state, trimTrailingChars(match[0], '.,;!?'), index + 1);
|
|
137
|
+
for (const badge of badgeTargets(prose)) inspectBadgeTarget(state, badge.alt, badge.target, index + 1);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function checkReadmeLines(state, lines) {
|
|
141
|
+
const scan = { fenced: false };
|
|
142
|
+
for (let index = 0; index < lines.length; index += 1) inspectReadmeLine(state, lines[index], index, scan);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function presentationResult(state) {
|
|
146
|
+
return { violations: state.violations, coverage: { status: 'checked', source: state.tree.source } };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function checkRepoPresentationImpl({ root, runtime, tree, profile, edition, nonNpmEntries }) {
|
|
150
|
+
const state = { root, runtime, tree, profile, edition, nonNpmEntries, violations: [] };
|
|
151
|
+
checkRootPresentation(state);
|
|
152
|
+
if (!tree.hasFile('README.md')) return presentationResult(state);
|
|
153
|
+
let readme;
|
|
154
|
+
try { readme = fs.readFileSync(path.join(root, 'README.md'), 'utf8'); }
|
|
155
|
+
catch {
|
|
156
|
+
addFinding(state, 'HFS_ROOT_ENTRY_MISSING', 'README.md', 'Tracked README.md is not readable.');
|
|
157
|
+
return presentationResult(state);
|
|
158
|
+
}
|
|
159
|
+
const lines = readme.split(/\r?\n/u);
|
|
160
|
+
const headings = checkReadmeHeadings(state, lines);
|
|
161
|
+
checkReadmeDevelopment(state, lines, headings);
|
|
162
|
+
checkReadmeWorkPointer(state, lines, headings);
|
|
163
|
+
checkReadmeLines(state, lines);
|
|
164
|
+
return presentationResult(state);
|
|
165
|
+
}
|
|
@@ -9,12 +9,16 @@
|
|
|
9
9
|
* must resolve to a route of that app (page.tsx / route.ts under src/app, route groups and [locale] transparent).
|
|
10
10
|
* Only literals are judged: a computed href is counted as skipped, never guessed at.
|
|
11
11
|
*/
|
|
12
|
+
import { byCodeUnit } from '../../lib/list.mjs';
|
|
13
|
+
|
|
12
14
|
export const REACHABILITY_RULE_IDS = ['BE_FEATURE_NOT_COMPOSED', 'FE_OWNER_REACHABLE', 'FE_HREF_RESOLVES'];
|
|
13
15
|
|
|
14
16
|
const BE_CAPABILITY_SLOTS = new Set(['be.domain', 'be.integrations', 'be.platform']);
|
|
15
17
|
const TEST_FILE = /(?:\.(?:spec|test|stories|e2e-spec)\.[cm]?[jt]sx?$)|(?:(?:^|\/)__tests__\/)/;
|
|
16
18
|
const ROUTE_FILE = /^(?:page|route)\.(?:tsx|ts|jsx|js)$/;
|
|
17
19
|
const PLACEHOLDER = '\u0000';
|
|
20
|
+
const REGEX_SPECIALS = /[.*+?^${}()|[\]\\]/g;
|
|
21
|
+
const ESCAPE_MATCH = String.raw`\$&`;
|
|
18
22
|
|
|
19
23
|
/** Breadth-first reach over runtime, non-type-only edges (re-exports included) from `roots`. */
|
|
20
24
|
function reachFrom(graph, roots) {
|
|
@@ -50,11 +54,11 @@ function ownerFiles(graph) {
|
|
|
50
54
|
/** The file that stands for an owner in a finding: its public entry when the graph holds one, else its first file. */
|
|
51
55
|
function entryOf(root, files) {
|
|
52
56
|
const base = root.replace(/\/$/, '');
|
|
53
|
-
|
|
57
|
+
const entry = new RegExp(String.raw`^${base.replace(REGEX_SPECIALS, ESCAPE_MATCH)}/index\.[tj]sx?$`);
|
|
58
|
+
return files.find(rel => entry.test(rel)) ?? [...files].sort(byCodeUnit)[0];
|
|
54
59
|
}
|
|
55
60
|
|
|
56
|
-
function
|
|
57
|
-
const byUnit = ownerFiles(graph);
|
|
61
|
+
function backendAppRoots(byUnit) {
|
|
58
62
|
const rootFiles = [];
|
|
59
63
|
const appRoots = new Set();
|
|
60
64
|
for (const [key, files] of byUnit) {
|
|
@@ -63,11 +67,10 @@ function checkBackend(graph) {
|
|
|
63
67
|
appRoots.add(key.slice(key.indexOf(':') + 1));
|
|
64
68
|
for (const rel of files) if (!TEST_FILE.test(rel)) rootFiles.push(rel);
|
|
65
69
|
}
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
const violations = [];
|
|
70
|
+
return { rootFiles, roots: [...appRoots].sort(byCodeUnit) };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function reportUncomposedBackendOwners(byUnit, reached, roots, counts, violations) {
|
|
71
74
|
for (const [key, files] of [...byUnit].sort(([a], [b]) => a.localeCompare(b))) {
|
|
72
75
|
const slot = key.slice(0, key.indexOf(':'));
|
|
73
76
|
const root = key.slice(key.indexOf(':') + 1);
|
|
@@ -83,10 +86,41 @@ function checkBackend(graph) {
|
|
|
83
86
|
message: `${feature ? 'Feature' : 'Module'} ${label} is not composed into any app: no runtime import path leads from ${roots.join(', ')} to it (a type-only import composes nothing), so it is dead code that no process serves.`,
|
|
84
87
|
});
|
|
85
88
|
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function checkBackend(graph) {
|
|
92
|
+
const byUnit = ownerFiles(graph);
|
|
93
|
+
const { rootFiles, roots } = backendAppRoots(byUnit);
|
|
94
|
+
const counts = { features: 0, modules: 0, appRoots: roots.length, notComposed: 0 };
|
|
95
|
+
if (!roots.length) return { violations: [], coverage: { status: 'unavailable', reason: 'no be.app.* owner instance holds a source file, so there is no app root to compose into', ...counts } };
|
|
96
|
+
const reached = reachFrom(graph, rootFiles);
|
|
97
|
+
const violations = [];
|
|
98
|
+
reportUncomposedBackendOwners(byUnit, reached, roots, counts, violations);
|
|
86
99
|
return { violations, coverage: { status: 'checked', ...counts } };
|
|
87
100
|
}
|
|
88
101
|
|
|
89
102
|
/** Route table of one app: arrays of segments, each {kind:'static'|'dyn'|'catch'|'optcatch', text}. */
|
|
103
|
+
function routeSegmentsOf(parts) {
|
|
104
|
+
const segments = [];
|
|
105
|
+
let usable = true;
|
|
106
|
+
for (const part of parts) {
|
|
107
|
+
if (/^\([^.)][^)]*\)$/.test(part)) continue; // route group
|
|
108
|
+
if (part.startsWith('@')) continue; // parallel slot: no URL segment
|
|
109
|
+
if (part.startsWith('_') || /^\(\.+\)/.test(part)) { usable = false; break; } // private folder, intercepting route
|
|
110
|
+
segments.push(part);
|
|
111
|
+
}
|
|
112
|
+
if (!usable) return null;
|
|
113
|
+
if (segments[0] === '[locale]') segments.shift();
|
|
114
|
+
return segments;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function routeSegmentOf(part) {
|
|
118
|
+
if (/^\[\[\.\.\..+\]\]$/.test(part)) return { kind: 'optcatch', text: part };
|
|
119
|
+
if (/^\[\.\.\..+\]$/.test(part)) return { kind: 'catch', text: part };
|
|
120
|
+
if (/^\[.+\]$/.test(part)) return { kind: 'dyn', text: part };
|
|
121
|
+
return { kind: 'static', text: part };
|
|
122
|
+
}
|
|
123
|
+
|
|
90
124
|
function routeTable(graph, app) {
|
|
91
125
|
const prefix = `apps/${app}/src/app/`;
|
|
92
126
|
const routes = [];
|
|
@@ -94,22 +128,9 @@ function routeTable(graph, app) {
|
|
|
94
128
|
if (!rel.startsWith(prefix)) continue;
|
|
95
129
|
const parts = rel.slice(prefix.length).split('/');
|
|
96
130
|
if (!ROUTE_FILE.test(parts.at(-1))) continue;
|
|
97
|
-
const segments =
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
if (/^\([^.)][^)]*\)$/.test(part)) continue; // route group
|
|
101
|
-
if (part.startsWith('@')) continue; // parallel slot: no URL segment
|
|
102
|
-
if (part.startsWith('_') || /^\(\.+\)/.test(part)) { usable = false; break; } // private folder, intercepting route
|
|
103
|
-
segments.push(part);
|
|
104
|
-
}
|
|
105
|
-
if (!usable) continue;
|
|
106
|
-
if (segments[0] === '[locale]') segments.shift();
|
|
107
|
-
routes.push(segments.map(part => {
|
|
108
|
-
if (/^\[\[\.\.\..+\]\]$/.test(part)) return { kind: 'optcatch', text: part };
|
|
109
|
-
if (/^\[\.\.\..+\]$/.test(part)) return { kind: 'catch', text: part };
|
|
110
|
-
if (/^\[.+\]$/.test(part)) return { kind: 'dyn', text: part };
|
|
111
|
-
return { kind: 'static', text: part };
|
|
112
|
-
}));
|
|
131
|
+
const segments = routeSegmentsOf(parts.slice(0, -1));
|
|
132
|
+
if (segments === null) continue;
|
|
133
|
+
routes.push(segments.map(routeSegmentOf));
|
|
113
134
|
}
|
|
114
135
|
return routes;
|
|
115
136
|
}
|
|
@@ -118,7 +139,7 @@ function routeTable(graph, app) {
|
|
|
118
139
|
function segmentMatches(hrefSegment, routeSegment) {
|
|
119
140
|
if (!hrefSegment.includes(PLACEHOLDER)) return routeSegment.kind === 'dyn' || routeSegment.text === hrefSegment;
|
|
120
141
|
if (routeSegment.kind === 'dyn' || routeSegment.kind === 'catch' || routeSegment.kind === 'optcatch') return true;
|
|
121
|
-
const parts = hrefSegment.split(PLACEHOLDER).map(part => part.replace(/[.*+?^${}()|[\]\\]/g,
|
|
142
|
+
const parts = hrefSegment.split(PLACEHOLDER).map(part => part.replace(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`));
|
|
122
143
|
return new RegExp(`^${parts.join('.*')}$`).test(routeSegment.text);
|
|
123
144
|
}
|
|
124
145
|
|
|
@@ -147,7 +168,7 @@ function hrefResolves(href, routes) {
|
|
|
147
168
|
/** The text of a string-ish expression with PLACEHOLDER for each ${...}; null when it is not a literal we can read. */
|
|
148
169
|
function literalText(ts, node) {
|
|
149
170
|
if (!node) return null;
|
|
150
|
-
while (ts.isParenthesizedExpression(node) || ts.isAsExpression(node) ||
|
|
171
|
+
while (ts.isParenthesizedExpression(node) || ts.isAsExpression(node) || ts.isNonNullExpression?.(node)) node = node.expression;
|
|
151
172
|
if (ts.isStringLiteralLike(node)) return node.text; // includes NoSubstitutionTemplateLiteral
|
|
152
173
|
if (ts.isTemplateExpression(node)) return node.head.text + node.templateSpans.map(span => PLACEHOLDER + span.literal.text).join('');
|
|
153
174
|
return null;
|
|
@@ -157,28 +178,47 @@ const NAVIGATION_CALLS = new Set(['redirect', 'permanentRedirect']);
|
|
|
157
178
|
const ROUTER_METHODS = new Set(['push', 'replace', 'prefetch']);
|
|
158
179
|
|
|
159
180
|
/** Every href-like target in a source file: {node, text|null (null = computed)}. */
|
|
181
|
+
function jsxHrefTarget(ts, node) {
|
|
182
|
+
if (!ts.isJsxAttribute(node) || !ts.isIdentifier(node.name) || node.name.text !== 'href' || !node.initializer) return null;
|
|
183
|
+
const value = ts.isJsxExpression(node.initializer) ? node.initializer.expression : node.initializer;
|
|
184
|
+
return { node: value ?? node, text: value ? literalText(ts, value) : null };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function routerNameOf(ts, callee) {
|
|
188
|
+
if (ts.isPropertyAccessExpression(callee) && ts.isIdentifier(callee.expression)) return callee.expression.text;
|
|
189
|
+
if (ts.isPropertyAccessExpression(callee) && ts.isPropertyAccessExpression(callee.expression)) return callee.expression.name.text;
|
|
190
|
+
return '';
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function isNavigationTarget(ts, callee) {
|
|
194
|
+
const routerName = routerNameOf(ts, callee);
|
|
195
|
+
return (ts.isIdentifier(callee) && NAVIGATION_CALLS.has(callee.text))
|
|
196
|
+
|| (ts.isPropertyAccessExpression(callee) && ROUTER_METHODS.has(callee.name.text) && /router$/i.test(routerName));
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function callHrefTarget(ts, node) {
|
|
200
|
+
if (!ts.isCallExpression(node) || !node.arguments.length) return null;
|
|
201
|
+
const callee = node.expression;
|
|
202
|
+
if (!isNavigationTarget(ts, callee)) return null;
|
|
203
|
+
return { node: node.arguments[0], text: literalText(ts, node.arguments[0]) };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function hrefTargetOf(ts, node) {
|
|
207
|
+
return jsxHrefTarget(ts, node) ?? callHrefTarget(ts, node);
|
|
208
|
+
}
|
|
209
|
+
|
|
160
210
|
function hrefTargets(ts, sourceFile) {
|
|
161
211
|
const found = [];
|
|
162
212
|
const visit = node => {
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
found.push({ node: value ?? node, text: value ? literalText(ts, value) : null });
|
|
166
|
-
} else if (ts.isCallExpression(node) && node.arguments.length) {
|
|
167
|
-
const callee = node.expression;
|
|
168
|
-
const navigates = (ts.isIdentifier(callee) && NAVIGATION_CALLS.has(callee.text))
|
|
169
|
-
|| (ts.isPropertyAccessExpression(callee) && ROUTER_METHODS.has(callee.name.text)
|
|
170
|
-
&& /router$/i.test(ts.isIdentifier(callee.expression) ? callee.expression.text : ts.isPropertyAccessExpression(callee.expression) ? callee.expression.name.text : ''));
|
|
171
|
-
if (navigates) found.push({ node: node.arguments[0], text: literalText(ts, node.arguments[0]) });
|
|
172
|
-
}
|
|
213
|
+
const target = hrefTargetOf(ts, node);
|
|
214
|
+
if (target) found.push(target);
|
|
173
215
|
ts.forEachChild(node, visit);
|
|
174
216
|
};
|
|
175
217
|
visit(sourceFile);
|
|
176
218
|
return found;
|
|
177
219
|
}
|
|
178
220
|
|
|
179
|
-
function
|
|
180
|
-
const ts = context.ts ?? context.loaded.ts;
|
|
181
|
-
const byUnit = ownerFiles(graph);
|
|
221
|
+
function appRouteRoots(graph) {
|
|
182
222
|
const apps = new Map();
|
|
183
223
|
for (const [rel, node] of graph.files) {
|
|
184
224
|
if (node.slot !== 'fe.route') continue;
|
|
@@ -187,9 +227,10 @@ function checkFrontend(config, context, graph) {
|
|
|
187
227
|
if (!apps.has(app)) apps.set(app, []);
|
|
188
228
|
apps.get(app).push(rel);
|
|
189
229
|
}
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
230
|
+
return apps;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function reportUnreachableFrontendFeatures(byUnit, reachedByApp, counts, violations) {
|
|
193
234
|
for (const [key, files] of [...byUnit].sort(([a], [b]) => a.localeCompare(b))) {
|
|
194
235
|
if (!key.startsWith('fe.feature:')) continue;
|
|
195
236
|
const root = key.slice('fe.feature:'.length);
|
|
@@ -203,27 +244,52 @@ function checkFrontend(config, context, graph) {
|
|
|
203
244
|
message: `${label} is not mounted: no runtime import path leads from a route file under apps/${app}/src/app to it (a type-only import mounts nothing), so the page is never shown.`,
|
|
204
245
|
});
|
|
205
246
|
}
|
|
206
|
-
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function routeApps(graph, apps) {
|
|
250
|
+
return [...new Set([...apps.keys(), ...[...graph.files.keys()].map(rel => /^apps\/([^/]+)\/src\//.exec(rel)?.[1]).filter(Boolean)])].sort(byCodeUnit);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function hrefNeedsSkipping(text, routes) {
|
|
254
|
+
if (text === null || !text.startsWith('/') || text.startsWith('//') || !routes.length) return true;
|
|
255
|
+
const hrefPath = text.split(/[?#]/)[0];
|
|
256
|
+
return /\.[A-Za-z0-9]{1,6}$/.test(hrefPath.split('/').at(-1)); // static asset
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function reportUnresolvedHref(app, rel, node, target, prefix, violations) {
|
|
260
|
+
const point = node.sourceFile.getLineAndCharacterOfPosition(target.node.getStart(node.sourceFile));
|
|
261
|
+
const shown = target.text.split(PLACEHOLDER).join('${…}');
|
|
262
|
+
violations.push({
|
|
263
|
+
ruleId: 'FE_HREF_RESOLVES', path: rel, line: point.line + 1, column: point.character + 1, app, href: shown,
|
|
264
|
+
message: `href "${shown}" matches no route of app ${app}: no page.tsx or route.ts under ${prefix}app serves that path.`,
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function inspectAppHrefTargets(app, graph, ts, routes, counts, violations) {
|
|
269
|
+
const prefix = `apps/${app}/src/`;
|
|
270
|
+
for (const [rel, node] of graph.files) {
|
|
271
|
+
if (!rel.startsWith(prefix) || TEST_FILE.test(rel)) continue;
|
|
272
|
+
for (const target of hrefTargets(ts, node.sourceFile)) {
|
|
273
|
+
counts.hrefs += 1;
|
|
274
|
+
if (hrefNeedsSkipping(target.text, routes)) { counts.hrefsSkipped += 1; continue; }
|
|
275
|
+
if (hrefResolves(target.text, routes)) { counts.hrefsResolved += 1; continue; }
|
|
276
|
+
reportUnresolvedHref(app, rel, node, target, prefix, violations);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function checkFrontend(config, context, graph) {
|
|
282
|
+
const ts = context.ts ?? context.loaded.ts;
|
|
283
|
+
const byUnit = ownerFiles(graph);
|
|
284
|
+
const apps = appRouteRoots(graph);
|
|
285
|
+
const counts = { pages: 0, mounted: 0, hrefs: 0, hrefsResolved: 0, hrefsSkipped: 0, routes: 0 };
|
|
286
|
+
const violations = [];
|
|
287
|
+
const reachedByApp = new Map([...apps].map(([app, roots]) => [app, reachFrom(graph, roots)]));
|
|
288
|
+
reportUnreachableFrontendFeatures(byUnit, reachedByApp, counts, violations);
|
|
289
|
+
for (const app of routeApps(graph, apps)) {
|
|
207
290
|
const routes = routeTable(graph, app);
|
|
208
291
|
counts.routes += routes.length;
|
|
209
|
-
|
|
210
|
-
for (const [rel, node] of graph.files) {
|
|
211
|
-
if (!rel.startsWith(prefix) || TEST_FILE.test(rel)) continue;
|
|
212
|
-
for (const target of hrefTargets(ts, node.sourceFile)) {
|
|
213
|
-
counts.hrefs += 1;
|
|
214
|
-
const text = target.text;
|
|
215
|
-
if (text === null || !text.startsWith('/') || text.startsWith('//') || !routes.length) { counts.hrefsSkipped += 1; continue; }
|
|
216
|
-
const hrefPath = text.split(/[?#]/)[0];
|
|
217
|
-
if (/\.[A-Za-z0-9]{1,6}$/.test(hrefPath.split('/').at(-1))) { counts.hrefsSkipped += 1; continue; } // static asset
|
|
218
|
-
if (hrefResolves(text, routes)) { counts.hrefsResolved += 1; continue; }
|
|
219
|
-
const point = node.sourceFile.getLineAndCharacterOfPosition(target.node.getStart(node.sourceFile));
|
|
220
|
-
const shown = text.split(PLACEHOLDER).join('${…}');
|
|
221
|
-
violations.push({
|
|
222
|
-
ruleId: 'FE_HREF_RESOLVES', path: rel, line: point.line + 1, column: point.character + 1, app, href: shown,
|
|
223
|
-
message: `href "${shown}" matches no route of app ${app}: no page.tsx or route.ts under ${prefix}app serves that path.`,
|
|
224
|
-
});
|
|
225
|
-
}
|
|
226
|
-
}
|
|
292
|
+
inspectAppHrefTargets(app, graph, ts, routes, counts, violations);
|
|
227
293
|
}
|
|
228
294
|
return { violations, coverage: { status: 'checked', ...counts } };
|
|
229
295
|
}
|