@starci/hfs 2.0.2 → 4.0.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 +25 -0
- package/README.md +33 -33
- package/bin/hfs.mjs +94 -68
- package/lint/run.mjs +161 -0
- package/package.json +3 -2
- package/report/sonar.mjs +14 -29
- package/runtime/engine/admission.mjs +3 -3
- package/runtime/engine/ledger-db.mjs +2 -2
- package/runtime/engine/machine-db.mjs +90 -9
- package/runtime/engine/migrations/machine/0002-worktrees-no-workflow-kind.sql +13 -0
- package/runtime/engine/migrations/runtime/0005-ended-workflow-views.sql +93 -0
- package/runtime/knowledge/hfs/canon-pins.yaml +32 -13
- package/runtime/knowledge/hfs/peer-integrations.yaml +18 -0
- package/runtime/knowledge/hfs/slots.yaml +193 -128
- package/runtime/knowledge/patterns/fe/folder.yaml +36 -36
- package/runtime/knowledge/sonar-gate.yaml +8 -7
- package/runtime/modules/kernel/failure-codes.yaml +31 -52
- package/runtime/scripts/checks/architecture/backend.mjs +1 -1
- package/runtime/scripts/checks/architecture/config.mjs +31 -11
- package/runtime/scripts/checks/architecture/contracts.mjs +4 -4
- package/runtime/scripts/checks/architecture/fe-slot-allows.mjs +7 -3
- package/runtime/scripts/checks/architecture/framework-pinned.mjs +5 -47
- package/runtime/scripts/checks/architecture/frontend.mjs +6 -4
- package/runtime/scripts/checks/architecture/hfs-graph.mjs +1 -1
- package/runtime/scripts/checks/architecture/hfs.mjs +105 -67
- package/runtime/scripts/checks/architecture/index.mjs +18 -13
- package/runtime/scripts/checks/architecture/next-data.mjs +3 -2
- package/runtime/scripts/checks/architecture/owners.mjs +12 -6
- package/runtime/scripts/checks/architecture/registration.mjs +1 -1
- package/runtime/scripts/checks/architecture/surface.mjs +91 -0
- package/runtime/scripts/checks/architecture/symbols.mjs +13 -2
- package/runtime/scripts/checks/architecture/test-world-files.mjs +83 -45
- package/runtime/scripts/checks/architecture/typescript.mjs +127 -29
- package/runtime/scripts/checks/typescript-programs.mjs +2 -2
- package/runtime/scripts/lib/hfs-check.mjs +160 -209
- package/runtime/scripts/lib/hfs-path-findings.mjs +95 -0
- package/runtime/scripts/lib/hfs-rules/contract.mjs +15 -42
- package/runtime/scripts/lib/hfs-rules/frontend.mjs +37 -36
- package/runtime/scripts/lib/hfs-rules/peer-integrations.mjs +44 -0
- package/runtime/scripts/lib/hfs-rules/pipeline.mjs +12 -13
- package/runtime/scripts/lib/hfs-rules/proof-commands.mjs +11 -8
- package/runtime/scripts/lib/hfs-rules/spec-placement.mjs +1 -3
- package/runtime/scripts/lib/hfs-slots.mjs +244 -61
- package/runtime/scripts/lib/hfs-view.mjs +9 -7
- package/runtime/scripts/lib/language.mjs +11 -1
- package/runtime/scripts/lib/safe-remove.mjs +95 -10
- package/scaffold/app.mjs +179 -0
- package/scaffold/service.mjs +26 -16
- package/sync/cli.mjs +1 -1
- package/sync/hygiene.mjs +11 -8
- package/sync/index.mjs +109 -111
- package/sync/managed.mjs +9 -8
- package/sync/sonar-key.mjs +20 -22
- package/templates/{be → app}/ci-workflows/github/workflows/ci.yml +5 -11
- package/templates/app/gitignore +6 -0
- package/templates/app/hooks/husky/pre-commit +25 -0
- package/templates/app/hooks/husky/pre-push +7 -0
- package/templates/app/package-scripts/package.json +22 -0
- package/templates/{be → app}/quality-config/sonar-project.properties +4 -3
- package/templates/app/skeleton/.editorconfig +15 -0
- package/templates/app/skeleton/.gitattributes +2 -0
- package/templates/app/skeleton/.nvmrc +1 -0
- package/templates/app/skeleton/.starciwork/features/index.yaml +7 -0
- package/templates/app/skeleton/.starciwork/workspace.yaml +9 -0
- package/templates/app/skeleton/README.md +36 -0
- package/templates/app/skeleton/scripts/codegen.mjs +4 -0
- package/templates/{fe → app}/tool-config/prettierignore +4 -1
- package/templates/be/skeleton/.sops.yaml +2 -0
- package/templates/be/skeleton/.starcistacks/application-stacks.yaml +10 -0
- package/templates/be/skeleton/apps/__app__/src/__app__.options.ts +3 -0
- package/templates/be/skeleton/apps/__app__/src/app.module.ts +27 -6
- package/templates/be/skeleton/apps/__app__/src/main.ts +4 -1
- package/templates/be/skeleton/src/features/system-health/transport/http/live.controller.ts +2 -0
- package/templates/be/skeleton/src/modules/domain/identity/admission.policy.ts +11 -0
- package/templates/be/skeleton/src/modules/domain/identity/auth.guard.ts +25 -0
- package/templates/be/skeleton/src/modules/domain/identity/errors/identity.error.ts +16 -0
- package/templates/be/skeleton/src/modules/domain/identity/identity.contracts.ts +11 -0
- package/templates/be/skeleton/src/modules/domain/identity/identity.decorators.ts +8 -0
- package/templates/be/skeleton/src/modules/domain/identity/identity.module-definition.ts +7 -0
- package/templates/be/skeleton/src/modules/domain/identity/identity.module.ts +14 -0
- package/templates/be/skeleton/src/modules/domain/identity/identity.options.ts +2 -0
- package/templates/be/skeleton/src/modules/domain/identity/index.ts +6 -0
- package/templates/be/skeleton/src/modules/domain/identity/messages/identity.messages.ts +11 -0
- package/templates/be/skeleton/src/modules/platform/composition/composition.decorators.ts +8 -1
- package/templates/be/skeleton/src/modules/platform/composition/index.ts +1 -1
- package/templates/be/skeleton/src/modules/platform/config/env-source.config.ts +71 -23
- package/templates/be/skeleton/src/modules/platform/config/errors/config.error.ts +14 -16
- package/templates/be/skeleton/src/modules/platform/config/index.ts +1 -1
- package/templates/be/skeleton/src/modules/platform/config/server.config.ts +2 -12
- package/templates/be/skeleton/src/modules/platform/errors/domain.error.ts +16 -7
- package/templates/be/skeleton/src/modules/platform/errors/errors/errors.error.ts +16 -0
- package/templates/be/skeleton/src/modules/platform/errors/errors.contracts.ts +33 -0
- package/templates/be/skeleton/src/modules/platform/errors/errors.decorators.ts +16 -0
- package/templates/be/skeleton/src/modules/platform/errors/errors.filter.ts +32 -0
- package/templates/be/skeleton/src/modules/platform/errors/errors.log-events.ts +2 -2
- package/templates/be/skeleton/src/modules/platform/errors/errors.module-definition.ts +9 -0
- package/templates/be/skeleton/src/modules/platform/errors/errors.module.ts +19 -0
- package/templates/be/skeleton/src/modules/platform/errors/errors.options.ts +7 -0
- package/templates/be/skeleton/src/modules/platform/errors/errors.service.spec.ts +94 -0
- package/templates/be/skeleton/src/modules/platform/errors/errors.service.ts +47 -0
- package/templates/be/skeleton/src/modules/platform/errors/http-status.policy.ts +13 -0
- package/templates/be/skeleton/src/modules/platform/errors/index.ts +4 -1
- package/templates/be/skeleton/src/modules/platform/errors/messages/errors.messages.ts +11 -0
- package/templates/be/skeleton/src/modules/platform/http-security/errors/http-security.error.ts +19 -0
- package/templates/be/skeleton/src/modules/platform/http-security/execution-request.mapper.ts +5 -0
- package/templates/be/skeleton/src/modules/platform/http-security/http-security.config.ts +16 -0
- package/templates/be/skeleton/src/modules/platform/http-security/http-security.decorators.ts +10 -0
- package/templates/be/skeleton/src/modules/platform/http-security/http-security.module-definition.ts +9 -0
- package/templates/be/skeleton/src/modules/platform/http-security/http-security.module.ts +13 -0
- package/templates/be/skeleton/src/modules/platform/http-security/http-security.options.ts +17 -0
- package/templates/be/skeleton/src/modules/platform/http-security/index.ts +7 -0
- package/templates/be/skeleton/src/modules/platform/http-security/messages/http-security.messages.ts +13 -0
- package/templates/be/skeleton/src/modules/platform/http-security/origin.guard.ts +31 -0
- package/templates/be/skeleton/src/modules/platform/http-security/rate-limit.guard.ts +68 -0
- package/templates/be/skeleton/src/modules/platform/i18n/bundle-message-catalog.service.spec.ts +66 -0
- package/templates/be/skeleton/src/modules/platform/i18n/bundle-message-catalog.service.ts +30 -0
- package/templates/be/skeleton/src/modules/platform/i18n/i18n.contracts.ts +18 -0
- package/templates/be/skeleton/src/modules/platform/i18n/i18n.decorators.ts +23 -0
- package/templates/be/skeleton/src/modules/platform/i18n/i18n.module-definition.ts +9 -0
- package/templates/be/skeleton/src/modules/platform/i18n/i18n.module.ts +24 -0
- package/templates/be/skeleton/src/modules/platform/i18n/i18n.options.ts +7 -0
- package/templates/be/skeleton/src/modules/platform/i18n/i18n.port.ts +13 -0
- package/templates/be/skeleton/src/modules/platform/i18n/index.ts +4 -0
- package/templates/be/skeleton/src/modules/platform/i18n/request-locale.service.spec.ts +45 -0
- package/templates/be/skeleton/src/modules/platform/i18n/request-locale.service.ts +19 -0
- package/templates/be/skeleton/src/modules/platform/logging/index.ts +1 -1
- package/templates/be/skeleton/src/modules/platform/logging/json-logger.service.spec.ts +85 -68
- package/templates/be/skeleton/src/modules/platform/logging/json-logger.service.ts +14 -10
- package/templates/be/skeleton/src/modules/platform/logging/logging.decorators.ts +13 -0
- package/templates/be/skeleton/src/modules/platform/logging/logging.log-events.ts +4 -0
- package/templates/be/skeleton/src/modules/platform/logging/logging.port.ts +0 -1
- package/templates/be/skeleton/src/modules/platform/primitives/index.ts +2 -0
- package/templates/be/skeleton/src/modules/platform/primitives/outcome.contracts.ts +25 -0
- package/templates/be/skeleton/src/modules/platform/primitives/outcome.mapper.ts +24 -0
- package/templates/fe/skeleton/apps/__app__/postcss.config.mjs +7 -0
- package/templates/fe/skeleton/apps/__app__/src/app/[locale]/error.tsx +5 -17
- package/templates/fe/skeleton/apps/__app__/src/app/[locale]/layout.tsx +11 -22
- package/templates/fe/skeleton/apps/__app__/src/app/[locale]/loading.tsx +6 -0
- package/templates/fe/skeleton/apps/__app__/src/app/[locale]/not-found.tsx +3 -12
- package/templates/fe/skeleton/apps/__app__/src/app/[locale]/page.tsx +6 -24
- package/templates/fe/skeleton/apps/__app__/src/app/global-error.tsx +4 -18
- package/templates/fe/skeleton/apps/__app__/src/app/globals.css +5 -0
- package/templates/fe/skeleton/apps/__app__/src/components/composites/FailureScreen/index.tsx +28 -0
- package/templates/fe/skeleton/apps/__app__/src/features/layouts/LocaleShell/index.tsx +35 -0
- package/templates/fe/skeleton/apps/__app__/src/features/pages/ErrorPage/component.tsx +21 -0
- package/templates/fe/skeleton/apps/__app__/src/features/pages/ErrorPage/index.tsx +17 -0
- package/templates/fe/skeleton/apps/__app__/src/features/pages/GlobalErrorPage/component.tsx +26 -0
- package/templates/fe/skeleton/apps/__app__/src/features/pages/GlobalErrorPage/index.tsx +15 -0
- package/templates/fe/skeleton/apps/__app__/src/features/pages/HomePage/component.tsx +25 -0
- package/templates/fe/skeleton/apps/__app__/src/features/pages/HomePage/index.tsx +15 -0
- package/templates/fe/skeleton/apps/__app__/src/features/pages/LoadingPage/component.tsx +27 -0
- package/templates/fe/skeleton/apps/__app__/src/features/pages/LoadingPage/index.tsx +8 -0
- package/templates/fe/skeleton/apps/__app__/src/features/pages/NotFoundPage/component.tsx +32 -0
- package/templates/fe/skeleton/apps/__app__/src/features/pages/NotFoundPage/index.tsx +8 -0
- package/templates/fe/skeleton/apps/__app__/src/modules/config/index.ts +12 -0
- package/templates/fe/skeleton/apps/__app__/src/modules/i18n/index.ts +2 -0
- package/templates/fe/skeleton/apps/__app__/src/modules/i18n/messages/vi.json +6 -0
- package/templates/fe/{skeleton-app → skeleton}/apps/__app__/src/modules/i18n/request.ts +1 -0
- package/templates/fe/skeleton/apps/__app__/src/modules/routes/index.ts +4 -0
- package/templates/fe/{skeleton-app → skeleton}/apps/__app__/src/proxy.ts +1 -1
- package/runtime/scripts/checks/architecture/size-growth.mjs +0 -73
- package/sync/skeleton.mjs +0 -76
- package/templates/be/gitignore +0 -2
- package/templates/be/hooks/husky/pre-commit +0 -13
- package/templates/be/hooks/husky/pre-push +0 -7
- package/templates/be/package-scripts/package.json +0 -22
- package/templates/be/skeleton/scripts/.gitkeep +0 -0
- package/templates/be/skeleton/src/modules/platform/config/config.decorators.ts +0 -9
- package/templates/be/skeleton/src/modules/platform/errors/error.filter.ts +0 -20
- package/templates/be/tool-config/prettierignore +0 -8
- package/templates/fe/ci-workflows/github/workflows/ci.yml +0 -54
- package/templates/fe/gitignore +0 -3
- package/templates/fe/hooks/husky/pre-commit +0 -16
- package/templates/fe/hooks/husky/pre-push +0 -6
- package/templates/fe/package-scripts/package.json +0 -17
- package/templates/fe/parts/api-client.ts +0 -44
- package/templates/fe/parts/api-outcome.ts +0 -7
- package/templates/fe/quality-config/sonar-project.properties +0 -8
- package/templates/fe/skeleton/scripts/.gitkeep +0 -0
- package/templates/fe/skeleton-app/apps/__app__/src/modules/api/client.ts +0 -1
- package/templates/fe/skeleton-app/apps/__app__/src/modules/api/index.ts +0 -3
- package/templates/fe/skeleton-app/apps/__app__/src/modules/api/outcome.ts +0 -1
- package/templates/fe/skeleton-app/apps/__app__/src/modules/i18n/index.ts +0 -4
- package/templates/fe/skeleton-app/apps/__app__/src/modules/i18n/navigation.ts +0 -5
- package/templates/fe/skeleton-shared/apps/__app__/next.config.ts +0 -12
- package/templates/fe/skeleton-shared/apps/__app__/src/modules/api/index.ts +0 -2
- package/templates/fe/skeleton-shared/apps/__app__/src/modules/i18n/index.ts +0 -9
- package/templates/fe/skeleton-shared/apps/__app__/src/modules/i18n/request.ts +0 -5
- package/templates/fe/skeleton-shared/apps/__app__/src/proxy.ts +0 -5
- package/templates/fe/skeleton-shared/packages/__family__-api/package.json +0 -12
- package/templates/fe/skeleton-shared/packages/__family__-api/src/client.ts +0 -1
- package/templates/fe/skeleton-shared/packages/__family__-api/src/index.ts +0 -3
- package/templates/fe/skeleton-shared/packages/__family__-api/src/outcome.ts +0 -1
- package/templates/fe/skeleton-shared/packages/__family__-api/tsconfig.json +0 -5
- package/templates/fe/skeleton-shared/packages/__family__-i18n/package.json +0 -18
- package/templates/fe/skeleton-shared/packages/__family__-i18n/src/app.ts +0 -19
- package/templates/fe/skeleton-shared/packages/__family__-i18n/src/index.ts +0 -2
- package/templates/fe/skeleton-shared/packages/__family__-i18n/src/proxy.ts +0 -12
- package/templates/fe/skeleton-shared/packages/__family__-i18n/src/request.ts +0 -15
- package/templates/fe/skeleton-shared/packages/__family__-i18n/tsconfig.json +0 -5
- package/templates/fe/tool-config/prettierrc +0 -1
- /package/templates/{be → app}/ci-workflows/github/workflows/e2e.yml +0 -0
- /package/templates/{be → app}/starciwork.gitignore +0 -0
- /package/templates/{be → app}/tool-config/prettierrc +0 -0
- /package/templates/fe/{skeleton-app → skeleton}/apps/__app__/next.config.ts +0 -0
- /package/templates/fe/{skeleton-app → skeleton}/apps/__app__/src/modules/i18n/config.ts +0 -0
- /package/templates/fe/{skeleton-app → skeleton}/apps/__app__/src/modules/i18n/routing.ts +0 -0
|
@@ -3,25 +3,30 @@ import { allowsFile } from '../../lib/hfs-allows.mjs';
|
|
|
3
3
|
import { DEFAULT_ENVIRONMENT, STACKS_DIRECTORY, STATEFUL_KINDS, namesOfService, readStack } from '../../lib/stack-services.mjs';
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
|
-
* R47 `test-world-files` (BE_TEST_TOPOLOGY).
|
|
6
|
+
* R47 `test-world-files` (BE_TEST_TOPOLOGY). Judgements over the test world, read through slots, the repository's own
|
|
7
7
|
* stack definition (`.starcistacks/<env>`, scripts/lib/stack-services.mjs) and the world's declaration
|
|
8
|
-
* (`test-world.config.ts
|
|
8
|
+
* (`test-world.config.ts`, read in the shape @starci/test-world defines), never through a path or a name list:
|
|
9
9
|
*
|
|
10
10
|
* 1. Files. `src/tests/world/` is the only test infrastructure location, and it holds only what its slot `allows`
|
|
11
11
|
* (knowledge/hfs/slots.yaml be.tests.world: global-setup.ts, global-teardown.ts, use-test-world.ts, and fakes/; kit/ is
|
|
12
12
|
* its own slot, be.tests.world.kit) plus files at its root whose role suffix is in ruleParams.be.suffixes
|
|
13
13
|
* (`stripe.client.ts`, `checkout.contracts.ts`, `test-world.config.ts`, ...). Every tracked file below the world root is
|
|
14
14
|
* matched by the slot's own entries through `allowsFile`.
|
|
15
|
-
* 2.
|
|
16
|
-
* `
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
* `
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
15
|
+
* 2. The declaration. `test-world.config.ts` declares the world in ONE form, the named export R89 allows:
|
|
16
|
+
* `export const { useTestWorld, useSandbox } = defineTestWorld({ ... })` (an exported `const` initialized by a
|
|
17
|
+
* `defineTestWorld(<object literal>)` call). A config without it (a default export, or a declaration the machine cannot
|
|
18
|
+
* read) is refused: the world it declares could not be judged.
|
|
19
|
+
* 3. Fakes against the stack (owner refinement 2026-09-30). Every service the stack declares runs real in the world; a fake
|
|
20
|
+
* (a `fakes/<provider>/` folder, or a `fakes` entry of the declaration such as `smtp: smtpFake()`) is a network-edge fake
|
|
21
|
+
* of an external SaaS the team does not operate. A fake of a stack service (its own name, its image repository or a
|
|
22
|
+
* well-known alias of the image) is refused, except the one owner-approved exception: a stack service that is stateless
|
|
23
|
+
* compute needing special hardware or an external model (GPU inference, a self-hosted embedding model) whose `stacks` entry
|
|
24
|
+
* declares it faked: `stacks: { "<stack service>": { fakedBy: "<fake>", reason: "<non-empty>" } }`. A stack service of a
|
|
25
|
+
* stateful kind (database, cache, identity, storage, mail, queue, search) or with a persistent volume is never accepted, and
|
|
26
|
+
* an entry that names a stack service or a fake (a `fakes` entry or a `fakes/` folder) that does not exist, or has no
|
|
27
|
+
* reason, is refused as stale or empty. The declaration's `stack` (`".starcistacks/<env>"`) names the stack environment the
|
|
28
|
+
* world runs (default `dev`); the machine reads the literal object of the declaration through the TypeScript AST and
|
|
29
|
+
* nothing else of it.
|
|
25
30
|
*/
|
|
26
31
|
export const TEST_WORLD_FILES_RULE_IDS = ['BE_TEST_TOPOLOGY'];
|
|
27
32
|
|
|
@@ -29,6 +34,8 @@ const RULE = 'BE_TEST_TOPOLOGY';
|
|
|
29
34
|
const WORLD_SLOT = 'be.tests.world';
|
|
30
35
|
const FAKES_DIRECTORY = 'fakes/';
|
|
31
36
|
const CONFIG_FILE = 'test-world.config.ts';
|
|
37
|
+
const DEFINE = 'defineTestWorld';
|
|
38
|
+
const NAMED_FORM = `export const { useTestWorld, useSandbox } = ${DEFINE}({ ... })`;
|
|
32
39
|
const defaultEnvironment = DEFAULT_ENVIRONMENT;
|
|
33
40
|
const KEBAB = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
|
|
34
41
|
|
|
@@ -39,51 +46,70 @@ const unwrap = (ts, node) => {
|
|
|
39
46
|
return current;
|
|
40
47
|
};
|
|
41
48
|
const propertyOf = (ts, literal, key) => literal.properties.find(property => ts.isPropertyAssignment(property) && nameOf(ts, property.name) === key)?.initializer ?? null;
|
|
49
|
+
const isExported = (ts, statement) => (statement.modifiers ?? []).some(modifier => modifier.kind === ts.SyntaxKind.ExportKeyword);
|
|
50
|
+
const positionOf = (sourceFile, node) => {
|
|
51
|
+
const position = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile));
|
|
52
|
+
return { line: position.line + 1, column: position.character + 1 };
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/** The object literal of `defineTestWorld(<object literal>)`, or null. */
|
|
56
|
+
function defineLiteral(ts, expression) {
|
|
57
|
+
const call = unwrap(ts, expression);
|
|
58
|
+
if (!call || !ts.isCallExpression(call) || !ts.isIdentifier(call.expression) || call.expression.text !== DEFINE) return null;
|
|
59
|
+
const [first] = call.arguments;
|
|
60
|
+
const literal = first ? unwrap(ts, first) : null;
|
|
61
|
+
return literal && ts.isObjectLiteralExpression(literal) ? literal : null;
|
|
62
|
+
}
|
|
42
63
|
|
|
43
|
-
/** The
|
|
64
|
+
/** The declaration of the world: the object literal of an exported `const` initialized by `defineTestWorld({ ... })`, or null. */
|
|
44
65
|
function configLiteral(ts, sourceFile) {
|
|
45
66
|
for (const statement of sourceFile.statements) {
|
|
46
|
-
if (!ts.
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
const literal = first ? unwrap(ts, first) : null;
|
|
52
|
-
if (literal && ts.isObjectLiteralExpression(literal)) return literal;
|
|
67
|
+
if (!ts.isVariableStatement(statement) || !isExported(ts, statement)) continue;
|
|
68
|
+
if ((statement.declarationList.flags & ts.NodeFlags.Const) === 0) continue;
|
|
69
|
+
for (const declaration of statement.declarationList.declarations) {
|
|
70
|
+
const literal = declaration.initializer ? defineLiteral(ts, declaration.initializer) : null;
|
|
71
|
+
if (literal) return literal;
|
|
53
72
|
}
|
|
54
73
|
}
|
|
55
74
|
return null;
|
|
56
75
|
}
|
|
57
76
|
|
|
58
|
-
/** `
|
|
59
|
-
function
|
|
60
|
-
const node = literal ? propertyOf(ts, literal, '
|
|
77
|
+
/** `stack` as the environment it names: the last segment of `".starcistacks/<env>"`; null when absent or not a literal. */
|
|
78
|
+
function environmentOf(ts, literal) {
|
|
79
|
+
const node = literal ? propertyOf(ts, literal, 'stack') : null;
|
|
61
80
|
const value = node ? unwrap(ts, node) : null;
|
|
62
|
-
if (!value) return null;
|
|
63
|
-
|
|
64
|
-
if (ts.isObjectLiteralExpression(value)) return value.properties.map(property => nameOf(ts, property.name)).filter(Boolean);
|
|
65
|
-
return null;
|
|
81
|
+
if (!value || !ts.isStringLiteralLike(value)) return null;
|
|
82
|
+
return value.text.split(/[\\/]+/u).filter(Boolean).at(-1) ?? null;
|
|
66
83
|
}
|
|
67
84
|
|
|
68
|
-
/**
|
|
85
|
+
/** The keys of the declaration's `fakes` object literal, with their positions. */
|
|
86
|
+
function fakeEntriesOf(ts, sourceFile, literal) {
|
|
87
|
+
const node = literal ? propertyOf(ts, literal, 'fakes') : null;
|
|
88
|
+
const value = node ? unwrap(ts, node) : null;
|
|
89
|
+
if (!value || !ts.isObjectLiteralExpression(value)) return [];
|
|
90
|
+
return value.properties
|
|
91
|
+
.map(property => ({ name: nameOf(ts, property.name), ...positionOf(sourceFile, property) }))
|
|
92
|
+
.filter(entry => entry.name);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** The `stacks` entries that declare `fakedBy`: [{service, fake, reason, line, column}]; a value the reader cannot read statically has an empty fake and reason. */
|
|
69
96
|
function fakedByOf(ts, sourceFile, literal) {
|
|
70
|
-
const node = literal ? propertyOf(ts, literal, '
|
|
97
|
+
const node = literal ? propertyOf(ts, literal, 'stacks') : null;
|
|
71
98
|
const value = node ? unwrap(ts, node) : null;
|
|
72
99
|
if (!value || !ts.isObjectLiteralExpression(value)) return [];
|
|
73
100
|
const entries = [];
|
|
74
101
|
for (const property of value.properties) {
|
|
75
102
|
if (!ts.isPropertyAssignment(property)) continue;
|
|
76
|
-
const service = nameOf(ts, property.name);
|
|
77
103
|
const body = unwrap(ts, property.initializer);
|
|
78
|
-
|
|
79
|
-
const
|
|
80
|
-
|
|
104
|
+
if (!body || !ts.isObjectLiteralExpression(body)) continue;
|
|
105
|
+
const fake = propertyOf(ts, body, 'fakedBy');
|
|
106
|
+
if (!fake) continue;
|
|
107
|
+
const reason = propertyOf(ts, body, 'reason');
|
|
81
108
|
entries.push({
|
|
82
|
-
service,
|
|
83
|
-
fake:
|
|
109
|
+
service: nameOf(ts, property.name),
|
|
110
|
+
fake: ts.isStringLiteralLike(fake) ? fake.text : '',
|
|
84
111
|
reason: reason && ts.isStringLiteralLike(reason) ? reason.text.trim() : '',
|
|
85
|
-
|
|
86
|
-
column: position.character + 1,
|
|
112
|
+
...positionOf(sourceFile, property),
|
|
87
113
|
});
|
|
88
114
|
}
|
|
89
115
|
return entries;
|
|
@@ -123,7 +149,13 @@ export function checkTestWorldFiles(input) {
|
|
|
123
149
|
const configPath = worldRoot === null ? null : `${worldRoot}${CONFIG_FILE}`;
|
|
124
150
|
const configFile = configPath === null ? null : graph.files.get(configPath) ?? null;
|
|
125
151
|
const literal = configFile ? configLiteral(ts, configFile.sourceFile) : null;
|
|
126
|
-
|
|
152
|
+
if (configFile && !literal) {
|
|
153
|
+
violations.push({
|
|
154
|
+
ruleId: RULE, path: configPath, line: 1, column: 1, slot: WORLD_SLOT,
|
|
155
|
+
message: `${configPath} declares no world the machine can read. Declare it in the one named form \`${NAMED_FORM}\` (an exported const of a ${DEFINE} call with an object literal, imported from @starci/test-world); a default export is refused by R89 and is not read.`,
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
const environments = [(literal ? environmentOf(ts, literal) : null) ?? defaultEnvironment];
|
|
127
159
|
const services = [];
|
|
128
160
|
for (const environment of environments) {
|
|
129
161
|
let stack = null;
|
|
@@ -132,14 +164,15 @@ export function checkTestWorldFiles(input) {
|
|
|
132
164
|
}
|
|
133
165
|
const stackHint = `${STACKS_DIRECTORY}/${environments.join(', ')}`;
|
|
134
166
|
const statefulWhy = service => (STATEFUL_KINDS.has(service.kind) ? `a ${service.kind} holds data the app reads back` : service.persistent ? 'the stack gives it a persistent volume' : null);
|
|
135
|
-
const declared =
|
|
167
|
+
const declared = literal ? fakedByOf(ts, configFile.sourceFile, literal) : [];
|
|
168
|
+
const fakeEntries = literal ? fakeEntriesOf(ts, configFile.sourceFile, literal) : [];
|
|
136
169
|
|
|
137
170
|
for (const entry of declared) {
|
|
138
171
|
const service = services.find(candidate => candidate.name === entry.service);
|
|
139
172
|
const problems = [];
|
|
140
173
|
if (!service) problems.push(`${stackHint} declares no service ${entry.service}`);
|
|
141
174
|
else if (statefulWhy(service)) problems.push(`${service.name} is stateful (${statefulWhy(service)}) and always runs real`);
|
|
142
|
-
if (!fakeProviders.has(entry.fake)) problems.push(`there is no fakes/${entry.fake || '<fake>'}/ folder`);
|
|
175
|
+
if (!fakeProviders.has(entry.fake) && !fakeEntries.some(fake => fake.name === entry.fake)) problems.push(`there is no fakes entry and no fakes/${entry.fake || '<fake>'}/ folder of that name`);
|
|
143
176
|
if (entry.reason === '') problems.push('the reason is empty');
|
|
144
177
|
if (problems.length > 0) {
|
|
145
178
|
violations.push({
|
|
@@ -149,14 +182,19 @@ export function checkTestWorldFiles(input) {
|
|
|
149
182
|
}
|
|
150
183
|
}
|
|
151
184
|
|
|
152
|
-
|
|
153
|
-
|
|
185
|
+
// Every fake the world runs: the repository's fakes/<provider>/ folders and the fakes entries of the declaration.
|
|
186
|
+
const fakes = [
|
|
187
|
+
...[...fakeProviders].map(([provider, file]) => ({ name: provider, path: file, line: 1, column: 1, what: `fakes/${provider}/` })),
|
|
188
|
+
...fakeEntries.filter(entry => !fakeProviders.has(entry.name)).map(entry => ({ name: entry.name, path: configPath, line: entry.line, column: entry.column, what: `the fakes entry ${entry.name}` })),
|
|
189
|
+
].sort((a, b) => a.name.localeCompare(b.name));
|
|
190
|
+
for (const fake of fakes) {
|
|
191
|
+
const service = services.find(candidate => namesOfService(candidate).includes(fake.name));
|
|
154
192
|
if (!service) continue;
|
|
155
|
-
if (declared.some(entry => entry.service === service.name && entry.fake ===
|
|
193
|
+
if (declared.some(entry => entry.service === service.name && entry.fake === fake.name)) continue; // judged on its stacks entry above
|
|
156
194
|
const why = statefulWhy(service);
|
|
157
195
|
violations.push({
|
|
158
|
-
ruleId: RULE, path:
|
|
159
|
-
message:
|
|
196
|
+
ruleId: RULE, path: fake.path, line: fake.line, column: fake.column, slot: WORLD_SLOT,
|
|
197
|
+
message: `${fake.what} fakes ${service.name} (${service.image}), which ${stackHint} declares: a service of the repository's own stack runs real in the test world. ${why ? `${service.name} is stateful (${why}), so no exception applies. ` : `Only stateless compute that needs special hardware or an external model may be faked, and only when ${configPath ?? `${CONFIG_FILE} in the world`} declares it in its stacks entry ({ fakedBy, reason }). `}Delete the fake and let the world run the real service; fakes are otherwise only for external SaaS the team does not operate.`,
|
|
160
198
|
});
|
|
161
199
|
}
|
|
162
200
|
return { violations, coverage: { status: 'checked', files } };
|
|
@@ -5,6 +5,7 @@ import { canonical, isInside, slash } from './config.mjs';
|
|
|
5
5
|
import { sameOrUnder } from '../../lib/path-key.mjs';
|
|
6
6
|
import { createTypeScriptProgram, readTypeScriptProject, resolveTypeScriptModule, sharedInProgramRun, typeScriptProjectReferencePath } from '../typescript-programs.mjs';
|
|
7
7
|
import { readJsonFile } from '../../lib/json.mjs';
|
|
8
|
+
import { locateDeclaration } from '../../lib/hfs-slots.mjs';
|
|
8
9
|
|
|
9
10
|
const CODE_EXTENSIONS = /\.(?:[cm]?[jt]sx?)$/i;
|
|
10
11
|
const TEST_FILE = /(?:^|[.-])(?:spec|test)\.[cm]?[jt]sx?$/i;
|
|
@@ -16,14 +17,14 @@ export const GENERATED_SEGMENTS = new Set(['.next', '.turbo', '.vercel', '.outpu
|
|
|
16
17
|
export function isGeneratedPath(root, fileName) {
|
|
17
18
|
return slash(path.relative(root, fileName)).split('/').slice(0, -1).some(segment => GENERATED_SEGMENTS.has(segment));
|
|
18
19
|
}
|
|
19
|
-
// A `<tool>.config.*` or `<tool>.setup.*` module beside a package manifest
|
|
20
|
-
// postcss.config.mjs
|
|
21
|
-
// (src/config/database.config.ts) has
|
|
22
|
-
// reads tooling modules (a profile may declare one as source);
|
|
23
|
-
//
|
|
20
|
+
// A `<tool>.config.*` or `<tool>.setup.*` module at the root of a project (beside a package manifest or a tsconfig.json:
|
|
21
|
+
// next.config.ts and postcss.config.mjs of an app, which has no package.json of its own) is build tooling a broad `**/*.ts`
|
|
22
|
+
// include pulls in; a `*.config.ts` inside a source tree (src/config/database.config.ts) has neither beside it and stays source.
|
|
23
|
+
// The architecture program still reads tooling modules (a profile may declare one as source); the lint (hfs lint) judges one only
|
|
24
|
+
// through the repository's own eslint.config.
|
|
24
25
|
const TOOLING_MODULE = /^[^/]+\.(?:config|setup)\.[cm]?[jt]sx?$/i;
|
|
25
26
|
export function isToolingModule(fileName) {
|
|
26
|
-
return TOOLING_MODULE.test(path.basename(fileName)) && fs.existsSync(path.join(path.dirname(fileName),
|
|
27
|
+
return TOOLING_MODULE.test(path.basename(fileName)) && ['package.json', 'tsconfig.json'].some((manifest) => fs.existsSync(path.join(path.dirname(fileName), manifest)));
|
|
27
28
|
}
|
|
28
29
|
|
|
29
30
|
function diagnosticMessage(ts, diagnostic) {
|
|
@@ -48,9 +49,12 @@ function compilerError(ts, root, diagnostic, project, ruleId = 'ARCH_TSCONFIG_IN
|
|
|
48
49
|
};
|
|
49
50
|
}
|
|
50
51
|
|
|
51
|
-
/**
|
|
52
|
+
/**
|
|
53
|
+
* Load TypeScript through the target package boundary, never through StarCi's own dependency graph. A side folder of an app has no
|
|
54
|
+
* package.json of its own: the app root's one manifest is the boundary its TypeScript is installed under.
|
|
55
|
+
*/
|
|
52
56
|
export function loadTargetTypeScript(repositoryRoot) {
|
|
53
|
-
const packageFile = path.join(repositoryRoot, 'package.json');
|
|
57
|
+
const packageFile = path.join(locateDeclaration(repositoryRoot).appRoot, 'package.json');
|
|
54
58
|
if (!fs.existsSync(packageFile)) throw Error('ARCH_TYPESCRIPT_MISSING: target package.json is required to resolve target-installed TypeScript.');
|
|
55
59
|
const targetRequire = createRequire(packageFile);
|
|
56
60
|
let resolved;
|
|
@@ -184,12 +188,13 @@ function workspaceMetadata(config) {
|
|
|
184
188
|
const root = path.join(config.root, ...relative.split('/'));
|
|
185
189
|
const pkg = readJson(path.join(root, 'package.json'));
|
|
186
190
|
const routeRoots = config.frontend.routes.map(item => path.join(config.root, ...item.split('/')));
|
|
187
|
-
return { root: canonical(root), relative, name: typeof pkg.name === 'string' ? pkg.name : null, exports: pkg.exports,
|
|
191
|
+
return { root: canonical(root), relative, name: typeof pkg.name === 'string' ? pkg.name : null, exports: pkg.exports, manifest: pkg, workspace: true,
|
|
188
192
|
app: routeRoots.some(route => isInside(root, route))
|
|
189
193
|
|| backendAppRoots.some(appRoot => isInside(appRoot, root) || isInside(root, appRoot)) };
|
|
190
194
|
};
|
|
191
195
|
const roots = config.workspaces.map(create);
|
|
192
|
-
|
|
196
|
+
// A side of an app has no package.json: the app root's one manifest names the package the side's root files belong to.
|
|
197
|
+
const rootPackage = readJson(path.join(config.packageRoot ?? config.root, 'package.json')) ?? {};
|
|
193
198
|
roots.push({ root: canonical(config.root), relative: '.', name: typeof rootPackage.name === 'string' ? rootPackage.name : null, exports: rootPackage.exports,
|
|
194
199
|
app: config.frontend.routes.some(item => isInside(config.root, path.join(config.root, ...item.split('/')))) || config.kinds.includes('backend') });
|
|
195
200
|
return roots.sort((a, b) => b.root.length - a.root.length);
|
|
@@ -212,18 +217,19 @@ function exportTargetStrings(value) {
|
|
|
212
217
|
return [];
|
|
213
218
|
}
|
|
214
219
|
|
|
215
|
-
|
|
216
|
-
|
|
220
|
+
/** The package-relative export targets a request (`.` or `./sub`) of a workspace package declares, from exports, else types/main. */
|
|
221
|
+
function exportCandidates(workspace, specifier) {
|
|
222
|
+
if (!workspace.name || !sameOrUnder(specifier, workspace.name)) return [];
|
|
217
223
|
const request = specifier === workspace.name ? '.' : `.${specifier.slice(workspace.name.length)}`;
|
|
218
224
|
const declaration = workspace.exports;
|
|
219
225
|
let candidates = [];
|
|
220
226
|
if (typeof declaration === 'string' || Array.isArray(declaration)) {
|
|
221
|
-
if (request !== '.') return
|
|
227
|
+
if (request !== '.') return [];
|
|
222
228
|
candidates = exportTargetStrings(declaration);
|
|
223
229
|
} else if (declaration && typeof declaration === 'object') {
|
|
224
230
|
const keys = Object.keys(declaration);
|
|
225
231
|
if (!keys.some(key => key.startsWith('.'))) {
|
|
226
|
-
if (request !== '.') return
|
|
232
|
+
if (request !== '.') return [];
|
|
227
233
|
candidates = exportTargetStrings(declaration);
|
|
228
234
|
} else {
|
|
229
235
|
for (const key of keys) {
|
|
@@ -232,10 +238,80 @@ function packageExported(workspace, specifier, actualTarget) {
|
|
|
232
238
|
}
|
|
233
239
|
}
|
|
234
240
|
}
|
|
235
|
-
return candidates
|
|
241
|
+
return candidates;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const SOURCE_EXTENSIONS = { '.d.ts': ['.ts', '.tsx'], '.d.mts': ['.mts'], '.d.cts': ['.cts'], '.js': ['.ts', '.tsx'], '.jsx': ['.tsx'], '.mjs': ['.mts'], '.cjs': ['.cts'] };
|
|
245
|
+
|
|
246
|
+
/** rootDir and outDir of a workspace package's build tsconfig (tsconfig.build.json, else tsconfig.json), extends followed; null when it declares none. */
|
|
247
|
+
function workspaceBuildLayout(ts, workspace) {
|
|
248
|
+
if (workspace.layout !== undefined) return workspace.layout;
|
|
249
|
+
workspace.layout = null;
|
|
250
|
+
for (const name of ['tsconfig.build.json', 'tsconfig.json']) {
|
|
251
|
+
const file = path.join(workspace.root, name);
|
|
252
|
+
if (!fs.existsSync(file)) continue;
|
|
253
|
+
const read = ts.readConfigFile(file, ts.sys.readFile);
|
|
254
|
+
if (read.error) continue;
|
|
255
|
+
const { options } = ts.parseJsonConfigFileContent(read.config, ts.sys, workspace.root, undefined, file);
|
|
256
|
+
if (options.outDir) { workspace.layout = { rootDir: options.rootDir ? path.resolve(options.rootDir) : path.join(workspace.root, 'src'), outDir: path.resolve(options.outDir) }; break; }
|
|
257
|
+
}
|
|
258
|
+
return workspace.layout;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** The source file a built export target (`./dist/sub/index.js`, `.d.ts`) stands for: outDir back to rootDir, else the dist-to-src layout; null when none exists. */
|
|
262
|
+
function sourceOfTarget(ts, workspace, target) {
|
|
263
|
+
const absolute = path.resolve(workspace.root, target);
|
|
264
|
+
const layout = workspaceBuildLayout(ts, workspace);
|
|
265
|
+
const extension = Object.keys(SOURCE_EXTENSIONS).filter(item => absolute.endsWith(item)).sort((a, b) => b.length - a.length)[0];
|
|
266
|
+
const sourceRoots = [];
|
|
267
|
+
if (layout && isInside(layout.outDir, absolute)) sourceRoots.push([layout.outDir, layout.rootDir]);
|
|
268
|
+
const first = slash(path.relative(workspace.root, absolute)).split('/')[0];
|
|
269
|
+
if (first) sourceRoots.push([path.join(workspace.root, first), path.join(workspace.root, 'src')]);
|
|
270
|
+
if (!extension) return null;
|
|
271
|
+
for (const [from, to] of sourceRoots) {
|
|
272
|
+
const stem = path.join(to, path.relative(from, absolute)).slice(0, -extension.length);
|
|
273
|
+
for (const candidate of SOURCE_EXTENSIONS[extension]) if (fs.existsSync(stem + candidate)) return canonical(stem + candidate);
|
|
274
|
+
}
|
|
275
|
+
return null;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** The source entry a workspace import resolves to without a build: its export targets mapped back to source, `src/index.ts` for the package root. */
|
|
279
|
+
function workspaceSourceEntry(ts, workspace, specifier) {
|
|
280
|
+
if (!workspace.workspace || !workspace.name || !sameOrUnder(specifier, workspace.name)) return null;
|
|
281
|
+
let candidates = exportCandidates(workspace, specifier);
|
|
282
|
+
if (specifier === workspace.name && !workspace.exports) candidates = [workspace.manifest?.types, workspace.manifest?.typings, workspace.manifest?.module, workspace.manifest?.main].filter(item => typeof item === 'string');
|
|
283
|
+
for (const target of candidates) {
|
|
284
|
+
if (!target.startsWith('./') || target.includes('..')) continue;
|
|
285
|
+
const source = sourceOfTarget(ts, workspace, target);
|
|
286
|
+
if (source) return source;
|
|
287
|
+
}
|
|
288
|
+
if (specifier === workspace.name) {
|
|
289
|
+
for (const name of ['index.ts', 'index.tsx']) if (fs.existsSync(path.join(workspace.root, 'src', name))) return canonical(path.join(workspace.root, 'src', name));
|
|
290
|
+
}
|
|
291
|
+
return null;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** Every source file the package.json `exports` of a workspace package maps (each non-wildcard subpath, dist back to source): its public entries. */
|
|
295
|
+
export function workspaceExportSources(ts, workspace) {
|
|
296
|
+
const declaration = workspace.exports;
|
|
297
|
+
if (!declaration || typeof declaration !== 'object' || Array.isArray(declaration)) return [];
|
|
298
|
+
const sources = new Set();
|
|
299
|
+
for (const [key, value] of Object.entries(declaration)) {
|
|
300
|
+
if (!key.startsWith('.') || key.includes('*')) continue;
|
|
301
|
+
for (const target of exportTargetStrings(value)) {
|
|
302
|
+
if (!target.startsWith('./') || target.includes('..')) continue;
|
|
303
|
+
const source = sourceOfTarget(ts, workspace, target);
|
|
304
|
+
if (source) sources.add(source);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
return [...sources];
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function packageExported(ts, workspace, specifier, actualTarget) {
|
|
311
|
+
return exportCandidates(workspace, specifier).some(target => {
|
|
236
312
|
if (!target.startsWith('./') || target.includes('..')) return false;
|
|
237
313
|
const expected = canonical(path.resolve(workspace.root, target));
|
|
238
|
-
return expected === canonical(actualTarget);
|
|
314
|
+
return expected === canonical(actualTarget) || sourceOfTarget(ts, workspace, target) === canonical(actualTarget);
|
|
239
315
|
});
|
|
240
316
|
}
|
|
241
317
|
|
|
@@ -344,20 +420,29 @@ function typeScriptContext(config, loaded, paths) {
|
|
|
344
420
|
.map(item => compilerError(ts, config.root, item, relative, 'ARCH_SYNTAX_INVALID')));
|
|
345
421
|
projects.push({ relative, program, options: parsed.options });
|
|
346
422
|
}
|
|
347
|
-
const fileMap = new Map();
|
|
348
|
-
const checkerByFile = new Map();
|
|
349
423
|
const occurrences = new Map();
|
|
424
|
+
const sourceIn = new Map();
|
|
350
425
|
for (const project of projects) {
|
|
351
426
|
for (const sourceFile of project.program.getSourceFiles().filter(file => isProductionSource(config.root, file))) {
|
|
352
427
|
const name = canonical(sourceFile.fileName);
|
|
353
|
-
if (!fileMap.has(name)) {
|
|
354
|
-
fileMap.set(name, sourceFile);
|
|
355
|
-
checkerByFile.set(name, project.program.getTypeChecker());
|
|
356
|
-
}
|
|
357
428
|
if (!occurrences.has(name)) occurrences.set(name, []);
|
|
358
429
|
occurrences.get(name).push(project);
|
|
430
|
+
sourceIn.set(`${project.relative}|${name}`, sourceFile);
|
|
359
431
|
}
|
|
360
432
|
}
|
|
433
|
+
// The deepest project directory holding a file owns it: its source file, its checker and its resolution. A side or repository
|
|
434
|
+
// root tsconfig without paths must not shadow the app project whose aliases (`@/*`) the file actually uses.
|
|
435
|
+
const owningProject = (candidates, file) => candidates.filter(item => isInside(path.dirname(path.join(config.root, ...item.relative.split('/'))), file))
|
|
436
|
+
.sort((x, y) => y.relative.split('/').length - x.relative.split('/').length)[0] ?? candidates[0];
|
|
437
|
+
const fileMap = new Map();
|
|
438
|
+
const checkerByFile = new Map();
|
|
439
|
+
const ownerByFile = new Map();
|
|
440
|
+
for (const [name, candidates] of occurrences) {
|
|
441
|
+
const owner = owningProject(candidates, name);
|
|
442
|
+
ownerByFile.set(name, owner);
|
|
443
|
+
fileMap.set(name, sourceIn.get(`${owner.relative}|${name}`));
|
|
444
|
+
checkerByFile.set(name, owner.program.getTypeChecker());
|
|
445
|
+
}
|
|
361
446
|
const files = [...fileMap.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([, file]) => file);
|
|
362
447
|
const edges = new Map([...fileMap.keys()].map(file => [file, []]));
|
|
363
448
|
for (const [file, sourceFile] of fileMap) {
|
|
@@ -370,11 +455,7 @@ function typeScriptContext(config, loaded, paths) {
|
|
|
370
455
|
const workspaceNames = new Set(workspaces.map(item => item.name).filter(Boolean));
|
|
371
456
|
for (const [from, sourceFile] of fileMap) {
|
|
372
457
|
const owningWorkspace = workspaceOf(workspaces, from);
|
|
373
|
-
const
|
|
374
|
-
// The deepest project directory holding the file owns its resolution: a root tsconfig without paths must not
|
|
375
|
-
// shadow the app project whose aliases the file actually uses.
|
|
376
|
-
const project = candidates.filter(item => isInside(path.dirname(path.join(config.root, ...item.relative.split('/'))), from))
|
|
377
|
-
.sort((x, y) => y.relative.split('/').length - x.relative.split('/').length)[0] ?? candidates[0];
|
|
458
|
+
const project = ownerByFile.get(from);
|
|
378
459
|
if (!project) continue;
|
|
379
460
|
const references = moduleReferences(ts, sourceFile, project.program.getTypeChecker());
|
|
380
461
|
for (const item of references.unproven) errors.push({
|
|
@@ -385,7 +466,9 @@ function typeScriptContext(config, loaded, paths) {
|
|
|
385
466
|
message: `${item.kind} must use a string-literal module name so architecture coverage can resolve its dependency.`,
|
|
386
467
|
});
|
|
387
468
|
for (const reference of references.found) {
|
|
388
|
-
|
|
469
|
+
// A workspace package is read at its source, never at its build: the same import resolves whether or not dist exists.
|
|
470
|
+
const workspaceTarget = workspaces.filter(item => item.root !== canonical(config.root)).map(item => workspaceSourceEntry(ts, item, reference.specifier)).find(Boolean);
|
|
471
|
+
const resolvedName = workspaceTarget ?? resolveTypeScriptModule(ts, reference.specifier, sourceFile.fileName, project.options, host);
|
|
389
472
|
if (!resolvedName) {
|
|
390
473
|
const codeLike = !ASSET_EXTENSION.test(reference.specifier);
|
|
391
474
|
const workspaceImport = [...workspaceNames].some(name => sameOrUnder(reference.specifier, name));
|
|
@@ -412,6 +495,21 @@ function typeScriptContext(config, loaded, paths) {
|
|
|
412
495
|
// project is then the boundary it always was.
|
|
413
496
|
const reviewable = isInside(config.root, actualTarget)
|
|
414
497
|
|| (config.repository && isInside(config.repository, actualTarget) && !slash(actualTarget).includes('/node_modules/'));
|
|
498
|
+
// A side of an app (config.root is be/ or fe/) imports nothing of the app outside itself: the root holds no source, the other
|
|
499
|
+
// side is another program, and a declared read (sides.fe.reads, be/contracts/) is codegen input, never an import.
|
|
500
|
+
const crossesSide = config.packageRoot !== undefined && config.packageRoot !== config.root
|
|
501
|
+
&& isInside(config.packageRoot, actualTarget) && !isInside(config.root, actualTarget) && !slash(actualTarget).includes('/node_modules/');
|
|
502
|
+
if (internal && crossesSide) {
|
|
503
|
+
errors.push({
|
|
504
|
+
ruleId: 'ARCH_INTERNAL_IMPORT_OUTSIDE',
|
|
505
|
+
project: project.relative,
|
|
506
|
+
path: relativePath(config.root, sourceFile.fileName),
|
|
507
|
+
...sourceLocation(sourceFile, reference.node),
|
|
508
|
+
specifier: reference.specifier,
|
|
509
|
+
message: `Internal import ${reference.specifier} leaves the ${path.basename(config.root)} side for ${slash(path.relative(config.packageRoot, actualTarget))}; nothing crosses the sides of an app.`,
|
|
510
|
+
});
|
|
511
|
+
continue;
|
|
512
|
+
}
|
|
415
513
|
if (internal && !reviewable) {
|
|
416
514
|
errors.push({
|
|
417
515
|
ruleId: 'ARCH_INTERNAL_IMPORT_OUTSIDE',
|
|
@@ -443,7 +541,7 @@ function typeScriptContext(config, loaded, paths) {
|
|
|
443
541
|
if (!owningWorkspace.app && targetWorkspace.app) {
|
|
444
542
|
errors.push(boundaryViolation(config.root, edge, owningWorkspace, targetWorkspace, 'ARCH_PACKAGE_IMPORTS_APP', 'A reusable workspace package cannot depend on an application workspace.'));
|
|
445
543
|
}
|
|
446
|
-
if (!packageExported(targetWorkspace, reference.specifier, actualTarget)) {
|
|
544
|
+
if (!packageExported(ts, targetWorkspace, reference.specifier, actualTarget)) {
|
|
447
545
|
errors.push(boundaryViolation(config.root, edge, owningWorkspace, targetWorkspace, 'ARCH_PACKAGE_EXPORT_BYPASS', 'Cross-package imports must use the target package name and a declared package export.'));
|
|
448
546
|
}
|
|
449
547
|
}
|
|
@@ -4,8 +4,8 @@ import path from 'node:path';
|
|
|
4
4
|
|
|
5
5
|
// One TypeScript program per (compiler, root names, compiler options, project references) inside a program run, and
|
|
6
6
|
// one value per (kind, compiler, input) for what callers derive from those programs (the architecture context).
|
|
7
|
-
//
|
|
8
|
-
//
|
|
7
|
+
// A caller (canon-scan's architecture machine, a script checker) opens one run around its work and releases it after,
|
|
8
|
+
// so nothing built here outlives the run that built it. Outside a run every call
|
|
9
9
|
// builds afresh.
|
|
10
10
|
const runs = new AsyncLocalStorage();
|
|
11
11
|
|