@starci/hfs 1.0.1 → 2.0.1
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 +51 -0
- package/README.md +110 -12
- package/bin/hfs.mjs +119 -15
- package/emit/compiler.mjs +35 -0
- package/emit/contracts.mjs +97 -0
- package/emit/operations-worker.mjs +24 -0
- package/emit/operations.mjs +126 -0
- package/emit/schema-worker.mjs +117 -0
- package/emit/static-graph.mjs +670 -0
- package/emit/type-schema.mjs +145 -0
- package/package.json +4 -1
- package/report/sonar.mjs +180 -0
- package/runtime/engine/admission.mjs +284 -0
- package/runtime/engine/digest.mjs +10 -0
- package/runtime/engine/ledger-db.mjs +1245 -0
- package/runtime/engine/machine-db.mjs +1484 -0
- package/runtime/engine/migrations/machine/0001-init.sql +887 -0
- package/runtime/engine/migrations/runtime/0001-init.sql +1072 -0
- package/runtime/engine/migrations/runtime/0003-usage-unavailable.sql +13 -0
- package/runtime/engine/migrations/runtime/0004-attempt-why.sql +43 -0
- package/runtime/engine/plain-object.mjs +5 -0
- package/runtime/knowledge/hfs/canon-pins.yaml +16 -58
- package/runtime/knowledge/hfs/slots.yaml +405 -137
- package/runtime/knowledge/patterns/fe/folder.yaml +309 -0
- package/runtime/knowledge/sonar-gate.yaml +85 -0
- package/runtime/modules/kernel/failure-codes.yaml +1480 -16
- package/runtime/scripts/checks/architecture/backend.mjs +350 -0
- package/runtime/scripts/checks/architecture/background-unowned.mjs +107 -0
- package/runtime/scripts/checks/architecture/client-reaches-server.mjs +94 -0
- package/runtime/scripts/checks/architecture/clones.mjs +200 -0
- package/runtime/scripts/checks/architecture/config-unread.mjs +35 -0
- package/runtime/scripts/checks/architecture/config.mjs +310 -0
- package/runtime/scripts/checks/architecture/connection-map.mjs +208 -0
- package/runtime/scripts/checks/architecture/constructor-deps.mjs +100 -0
- package/runtime/scripts/checks/architecture/contract-fixture-guard.mjs +128 -0
- package/runtime/scripts/checks/architecture/contracts.mjs +792 -0
- package/runtime/scripts/checks/architecture/cross-app-duplicate.mjs +73 -0
- package/runtime/scripts/checks/architecture/dead-exports.mjs +265 -0
- package/runtime/scripts/checks/architecture/default-deny.mjs +129 -0
- package/runtime/scripts/checks/architecture/doc-language.mjs +39 -0
- package/runtime/scripts/checks/architecture/entrypoint.mjs +57 -0
- package/runtime/scripts/checks/architecture/error-codes.mjs +45 -0
- package/runtime/scripts/checks/architecture/error-masked.mjs +60 -0
- package/runtime/scripts/checks/architecture/fe-slot-allows.mjs +38 -0
- package/runtime/scripts/checks/architecture/feature-shape.mjs +49 -0
- package/runtime/scripts/checks/architecture/framework-pinned.mjs +83 -0
- package/runtime/scripts/checks/architecture/frontend.mjs +995 -0
- package/runtime/scripts/checks/architecture/hfs-graph.mjs +61 -0
- package/runtime/scripts/checks/architecture/hfs.mjs +521 -0
- package/runtime/scripts/checks/architecture/hooks-are-hooks.mjs +88 -0
- package/runtime/scripts/checks/architecture/i18n-keys.mjs +146 -0
- package/runtime/scripts/checks/architecture/index.mjs +316 -0
- package/runtime/scripts/checks/architecture/injection-token-exported.mjs +62 -0
- package/runtime/scripts/checks/architecture/machine-ast.mjs +138 -0
- package/runtime/scripts/checks/architecture/module-per-transport.mjs +130 -0
- package/runtime/scripts/checks/architecture/next-data.mjs +775 -0
- package/runtime/scripts/checks/architecture/owners.mjs +89 -0
- package/runtime/scripts/checks/architecture/package-shape.mjs +63 -0
- package/runtime/scripts/checks/architecture/reachability.mjs +233 -0
- package/runtime/scripts/checks/architecture/register-once.mjs +141 -0
- package/runtime/scripts/checks/architecture/registration.mjs +319 -0
- package/runtime/scripts/checks/architecture/required-files.mjs +172 -0
- package/runtime/scripts/checks/architecture/route-files-thin.mjs +97 -0
- package/runtime/scripts/checks/architecture/schema-owner.mjs +261 -0
- package/runtime/scripts/checks/architecture/size-growth.mjs +73 -0
- package/runtime/scripts/checks/architecture/source-names.mjs +607 -0
- package/runtime/scripts/checks/architecture/sql-owner.mjs +142 -0
- package/runtime/scripts/checks/architecture/sql-tokens.mjs +327 -0
- package/runtime/scripts/checks/architecture/symbols.mjs +193 -0
- package/runtime/scripts/checks/architecture/test-world-files.mjs +163 -0
- package/runtime/scripts/checks/architecture/tiers.mjs +130 -0
- package/runtime/scripts/checks/architecture/transport-owner.mjs +112 -0
- package/runtime/scripts/checks/architecture/typescript.mjs +500 -0
- package/runtime/scripts/checks/architecture/unit-spec-providers.mjs +122 -0
- package/runtime/scripts/checks/architecture.mjs +41 -0
- package/runtime/scripts/checks/common.mjs +37 -0
- package/runtime/scripts/checks/typescript-programs.mjs +82 -0
- package/runtime/scripts/lib/artifact-hold.mjs +89 -0
- package/runtime/scripts/lib/artifact-store.mjs +103 -0
- package/runtime/scripts/lib/fs-kind.mjs +10 -0
- package/runtime/scripts/lib/git.mjs +53 -0
- package/runtime/scripts/lib/hfs-allows.mjs +57 -0
- package/runtime/scripts/lib/hfs-check.mjs +254 -28
- package/runtime/scripts/lib/hfs-rules/contract.mjs +126 -0
- package/runtime/scripts/lib/hfs-rules/deps.mjs +63 -0
- package/runtime/scripts/lib/hfs-rules/fe-no-tests.mjs +47 -0
- package/runtime/scripts/lib/hfs-rules/frontend.mjs +124 -0
- package/runtime/scripts/lib/hfs-rules/lint-suppression.mjs +34 -0
- package/runtime/scripts/lib/hfs-rules/pipeline.mjs +51 -0
- package/runtime/scripts/lib/hfs-rules/proof-commands.mjs +64 -0
- package/runtime/scripts/lib/hfs-rules/read.mjs +28 -0
- package/runtime/scripts/lib/hfs-rules/repo-local-checks.mjs +32 -0
- package/runtime/scripts/lib/hfs-rules/secrets.mjs +54 -0
- package/runtime/scripts/lib/hfs-rules/spec-placement.mjs +31 -0
- package/runtime/scripts/lib/hfs-rules/stacks.mjs +54 -0
- package/runtime/scripts/lib/hfs-rules/test-topology.mjs +31 -0
- package/runtime/scripts/lib/hfs-slots.mjs +88 -40
- package/runtime/scripts/lib/hfs-tree.mjs +80 -0
- package/runtime/scripts/lib/hfs-view.mjs +68 -0
- package/runtime/scripts/lib/json.mjs +22 -0
- package/runtime/scripts/lib/language.mjs +107 -0
- package/runtime/scripts/lib/path-key.mjs +2 -0
- package/runtime/scripts/lib/redact.mjs +148 -0
- package/runtime/scripts/lib/repo-identity.mjs +50 -0
- package/runtime/scripts/lib/safe-remove.mjs +179 -0
- package/runtime/scripts/lib/secret-patterns.mjs +44 -0
- package/runtime/scripts/lib/sleep-sync.mjs +17 -0
- package/runtime/scripts/lib/stack-declaration.mjs +52 -0
- package/runtime/scripts/lib/stack-services.mjs +217 -0
- package/runtime/scripts/lib/test-secrets.mjs +120 -0
- package/scaffold/service.mjs +333 -0
- package/sync/format.mjs +46 -0
- package/sync/hygiene.mjs +56 -24
- package/sync/index.mjs +133 -41
- package/sync/managed.mjs +170 -0
- package/sync/skeleton.mjs +32 -10
- package/sync/sonar-key.mjs +13 -0
- package/sync/ts-strict.mjs +48 -0
- package/templates/{common → be/ci-workflows/github/workflows}/ci.yml +13 -13
- package/templates/be/{e2e.yml → ci-workflows/github/workflows/e2e.yml} +1 -0
- package/templates/be/hooks/husky/pre-commit +13 -0
- package/templates/be/hooks/husky/pre-push +7 -0
- package/templates/be/package-scripts/package.json +22 -0
- package/templates/be/{sonar-project.properties → quality-config/sonar-project.properties} +2 -3
- package/templates/be/skeleton/apps/__app__/src/app.module.ts +14 -4
- package/templates/be/skeleton/apps/__app__/src/main.ts +9 -6
- package/templates/be/skeleton/scripts/.gitkeep +0 -0
- package/templates/be/skeleton/src/features/system-health/application/check-liveness.contracts.ts +2 -0
- package/templates/be/skeleton/src/features/system-health/application/check-liveness.handler.ts +22 -0
- package/templates/be/skeleton/src/features/system-health/application/check-liveness.query.ts +11 -0
- package/templates/be/skeleton/src/features/system-health/index.ts +1 -1
- package/templates/be/skeleton/src/features/system-health/system-health.module.ts +3 -3
- package/templates/be/skeleton/src/features/system-health/transport/http/live.controller.ts +8 -9
- package/templates/be/skeleton/src/features/system-health/transport/http/system-health-http.module.ts +3 -6
- package/templates/be/skeleton/src/modules/domain/liveness/index.ts +3 -0
- package/templates/be/skeleton/src/modules/domain/liveness/liveness.contracts.ts +9 -0
- package/templates/be/skeleton/src/modules/domain/liveness/liveness.module-definition.ts +7 -0
- package/templates/be/skeleton/src/modules/domain/liveness/liveness.module.ts +14 -0
- package/templates/be/skeleton/src/modules/domain/liveness/liveness.options.ts +2 -0
- package/templates/be/skeleton/src/modules/domain/liveness/liveness.service.spec.ts +40 -0
- package/templates/be/skeleton/src/modules/domain/liveness/liveness.service.ts +26 -0
- package/templates/be/skeleton/src/modules/platform/clock/clock.decorators.ts +9 -0
- package/templates/be/skeleton/src/modules/platform/clock/clock.module-definition.ts +7 -0
- package/templates/be/skeleton/src/modules/platform/clock/clock.module.ts +19 -0
- package/templates/be/skeleton/src/modules/platform/clock/clock.options.ts +2 -0
- package/templates/be/skeleton/src/modules/platform/clock/clock.port.ts +5 -0
- package/templates/be/skeleton/src/modules/platform/clock/index.ts +4 -0
- package/templates/be/skeleton/src/modules/platform/clock/system-clock.service.spec.ts +20 -0
- package/templates/be/skeleton/src/modules/platform/clock/system-clock.service.ts +9 -0
- package/templates/be/skeleton/src/modules/platform/composition/composition.decorators.ts +8 -0
- package/templates/be/skeleton/src/modules/platform/composition/index.ts +2 -0
- package/templates/be/skeleton/src/modules/platform/config/config.decorators.ts +9 -0
- package/templates/be/skeleton/src/modules/platform/config/index.ts +2 -3
- package/templates/be/skeleton/src/modules/platform/config/server.config.ts +1 -1
- package/templates/be/skeleton/src/modules/platform/config/server.options.ts +0 -3
- package/templates/be/skeleton/src/modules/platform/cqrs/cqrs.contracts.ts +5 -0
- package/templates/be/skeleton/src/modules/platform/cqrs/cqrs.decorators.ts +9 -0
- package/templates/be/skeleton/src/modules/platform/cqrs/cqrs.handler.ts +27 -0
- package/templates/be/skeleton/src/modules/platform/cqrs/cqrs.log-events.ts +5 -0
- package/templates/be/skeleton/src/modules/platform/cqrs/cqrs.module-definition.ts +7 -0
- package/templates/be/skeleton/src/modules/platform/cqrs/cqrs.module.ts +18 -0
- package/templates/be/skeleton/src/modules/platform/cqrs/cqrs.options.ts +2 -0
- package/templates/be/skeleton/src/modules/platform/cqrs/index.ts +4 -0
- package/templates/be/skeleton/src/modules/platform/errors/error.filter.ts +8 -12
- package/templates/be/skeleton/src/modules/platform/errors/errors.log-events.ts +5 -0
- package/templates/be/skeleton/src/modules/platform/errors/index.ts +1 -1
- package/templates/be/skeleton/src/modules/platform/logging/index.ts +4 -4
- package/templates/be/skeleton/src/modules/platform/logging/json-logger.service.spec.ts +99 -0
- package/templates/be/skeleton/src/modules/platform/logging/json-logger.service.ts +38 -0
- package/templates/be/skeleton/src/modules/platform/logging/logging.decorators.ts +9 -0
- package/templates/be/skeleton/src/modules/platform/logging/logging.log-events.ts +7 -0
- package/templates/be/skeleton/src/modules/platform/logging/logging.module-definition.ts +7 -0
- package/templates/be/skeleton/src/modules/platform/logging/logging.module.ts +23 -10
- package/templates/be/skeleton/src/modules/platform/logging/logging.options.ts +2 -0
- package/templates/be/skeleton/src/modules/platform/logging/logging.port.ts +15 -0
- package/templates/be/tool-config/eslint.config.mjs +3 -0
- package/templates/be/tool-config/jest.config.js +1 -0
- package/templates/be/tool-config/prettierignore +8 -0
- package/templates/be/tool-config/prettierrc +1 -0
- package/templates/be/tool-config/src/tests/tsconfig.json +5 -0
- package/templates/be/tool-config/tsconfig.build.json +5 -0
- package/templates/be/tool-config/tsconfig.json +11 -0
- package/templates/common/gitignore.base +1 -1
- package/templates/fe/ci-workflows/github/workflows/ci.yml +54 -0
- package/templates/fe/hooks/husky/pre-commit +16 -0
- package/templates/fe/hooks/husky/pre-push +6 -0
- package/templates/fe/package-scripts/package.json +17 -0
- package/templates/fe/parts/api-client.ts +44 -0
- package/templates/fe/parts/api-outcome.ts +7 -0
- package/templates/fe/quality-config/sonar-project.properties +8 -0
- package/templates/fe/skeleton/apps/__app__/src/app/[locale]/layout.tsx +1 -1
- package/templates/fe/skeleton/apps/__app__/src/app/[locale]/not-found.tsx +1 -1
- package/templates/fe/skeleton/apps/__app__/src/app/global-error.tsx +1 -1
- package/templates/fe/skeleton/scripts/.gitkeep +0 -0
- package/templates/fe/skeleton-app/apps/__app__/src/modules/api/client.ts +1 -0
- package/templates/fe/skeleton-app/apps/__app__/src/modules/api/index.ts +3 -0
- package/templates/fe/skeleton-app/apps/__app__/src/modules/api/outcome.ts +1 -0
- package/templates/fe/skeleton-app/apps/__app__/src/modules/i18n/index.ts +4 -0
- package/templates/fe/skeleton-shared/apps/__app__/next.config.ts +12 -0
- package/templates/fe/skeleton-shared/apps/__app__/src/modules/api/index.ts +2 -0
- package/templates/fe/skeleton-shared/apps/__app__/src/modules/i18n/index.ts +9 -0
- package/templates/fe/skeleton-shared/apps/__app__/src/modules/i18n/request.ts +5 -0
- package/templates/fe/skeleton-shared/apps/__app__/src/proxy.ts +5 -0
- package/templates/fe/skeleton-shared/packages/__family__-api/package.json +12 -0
- package/templates/fe/skeleton-shared/packages/__family__-api/src/client.ts +1 -0
- package/templates/fe/skeleton-shared/packages/__family__-api/src/index.ts +3 -0
- package/templates/fe/skeleton-shared/packages/__family__-api/src/outcome.ts +1 -0
- package/templates/fe/skeleton-shared/packages/__family__-api/tsconfig.json +5 -0
- package/templates/fe/skeleton-shared/packages/__family__-i18n/package.json +18 -0
- package/templates/fe/skeleton-shared/packages/__family__-i18n/src/app.ts +19 -0
- package/templates/fe/skeleton-shared/packages/__family__-i18n/src/index.ts +2 -0
- package/templates/fe/skeleton-shared/packages/__family__-i18n/src/proxy.ts +12 -0
- package/templates/fe/skeleton-shared/packages/__family__-i18n/src/request.ts +15 -0
- package/templates/fe/skeleton-shared/packages/__family__-i18n/tsconfig.json +5 -0
- package/templates/fe/tool-config/eslint.config.mjs +3 -0
- package/templates/fe/tool-config/prettierignore +10 -0
- package/templates/fe/tool-config/prettierrc +1 -0
- package/templates/fe/tool-config/stylelint.config.mjs +3 -0
- package/templates/fe/tool-config/tsconfig.json +4 -0
- package/templates/be/pre-commit +0 -8
- package/templates/be/skeleton/apps/__app__/src/__app__.composition.spec.ts +0 -31
- package/templates/be/skeleton/src/features/system-health/transport/http/live.controller.spec.ts +0 -13
- package/templates/be/skeleton/src/modules/platform/config/env-source.spec.ts +0 -36
- package/templates/be/skeleton/src/modules/platform/config/server.config.spec.ts +0 -19
- package/templates/be/skeleton/src/modules/platform/errors/domain-error.spec.ts +0 -15
- package/templates/be/skeleton/src/modules/platform/errors/error.filter.spec.ts +0 -38
- package/templates/be/skeleton/src/modules/platform/logging/json-logger.spec.ts +0 -33
- package/templates/be/skeleton/src/modules/platform/logging/json-logger.ts +0 -35
- package/templates/be/skeleton/src/modules/platform/logging/log-id.ts +0 -9
- package/templates/be/skeleton/src/modules/platform/logging/logger.port.ts +0 -19
- package/templates/common/codecov.yml +0 -13
- package/templates/common/pre-push +0 -5
- package/templates/fe/e2e.yml +0 -22
- package/templates/fe/pre-commit +0 -7
- package/templates/fe/skeleton/apps/__app__/src/app/health/live/route.spec.ts +0 -10
- package/templates/fe/skeleton/apps/__app__/src/modules/i18n/messages.spec.ts +0 -27
- package/templates/fe/skeleton/apps/__app__/src/modules/i18n/routing.spec.ts +0 -10
- package/templates/fe/sonar-project.properties +0 -11
- /package/templates/be/skeleton/src/modules/platform/config/{env-source.ts → env-source.config.ts} +0 -0
- /package/templates/be/skeleton/src/modules/platform/errors/{domain-error.ts → domain.error.ts} +0 -0
- /package/templates/fe/{skeleton → skeleton-app}/apps/__app__/next.config.ts +0 -0
- /package/templates/fe/{skeleton → skeleton-app}/apps/__app__/src/modules/i18n/config.ts +0 -0
- /package/templates/fe/{skeleton → skeleton-app}/apps/__app__/src/modules/i18n/navigation.ts +0 -0
- /package/templates/fe/{skeleton → skeleton-app}/apps/__app__/src/modules/i18n/request.ts +0 -0
- /package/templates/fe/{skeleton → skeleton-app}/apps/__app__/src/modules/i18n/routing.ts +0 -0
- /package/templates/fe/{skeleton → skeleton-app}/apps/__app__/src/proxy.ts +0 -0
package/sync/index.mjs
CHANGED
|
@@ -1,16 +1,21 @@
|
|
|
1
1
|
// hfs sync: render, verify and write the files a product repository cannot `extends`.
|
|
2
2
|
//
|
|
3
|
-
// The
|
|
4
|
-
//
|
|
5
|
-
//
|
|
3
|
+
// The managed set is read from the slot manifest (knowledge/hfs/slots.yaml): every slot that names `managedBy` lists,
|
|
4
|
+
// in its `path`, the whole files sync renders, and `managedBy` names the template directory they are rendered from:
|
|
5
|
+
// templates/<profile or common>/<managedBy>/<file path with each leading dot dropped>. Two targets are not whole files
|
|
6
|
+
// of a managedBy slot and are listed in this module: the marked block of the shared .gitignore, and
|
|
7
|
+
// .starciwork/.gitignore, which lives inside the .starciwork directory slot. Every file is rendered with the
|
|
8
|
+
// repository's hfs.json (profile and apps) and, for the Sonar exclusions, the same jest preset a back end
|
|
6
9
|
// repository installs. `--check` compares the sha256 of the rendered content with the file on disk and fails on any
|
|
7
10
|
// drift; `--write` rewrites the drifted files. `.gitignore` is the one shared file: only the marked block is managed
|
|
8
|
-
// and the repository's own lines around it are left alone.
|
|
11
|
+
// and the repository's own lines around it are left alone. A back end's package.json is managed by its `scripts`
|
|
12
|
+
// block only (mode scripts, compared as parsed JSON): the rest of the file is the repository's.
|
|
9
13
|
import { createHash } from 'node:crypto';
|
|
10
14
|
import { createRequire } from 'node:module';
|
|
11
15
|
import fs from 'node:fs';
|
|
12
16
|
import path from 'node:path';
|
|
13
|
-
import {
|
|
17
|
+
import { braceVariants } from '../runtime/scripts/lib/glob.mjs';
|
|
18
|
+
import { loadSlotManifest, resolveRepoDeclaration } from '../runtime/scripts/lib/hfs-slots.mjs';
|
|
14
19
|
import { readDeclaredSonarKey } from './sonar-key.mjs';
|
|
15
20
|
|
|
16
21
|
export const TEMPLATES_DIR = path.join(import.meta.dirname, '..', 'templates');
|
|
@@ -18,7 +23,7 @@ export const NODE_MAJOR = 22;
|
|
|
18
23
|
export const PROFILES = Object.freeze(['be', 'fe']);
|
|
19
24
|
export const BLOCK_BEGIN = '# >>> hfs sync (managed block; do not edit) >>>';
|
|
20
25
|
export const BLOCK_END = '# <<< hfs sync <<<';
|
|
21
|
-
const HEADER = profile => `# Generated by hfs sync (profile ${profile}). Do not edit: run "npx hfs sync --write"
|
|
26
|
+
const HEADER = profile => `# Generated by hfs sync (profile ${profile}). Do not edit: run "npx hfs sync --write".`;
|
|
22
27
|
|
|
23
28
|
export class SyncError extends Error {
|
|
24
29
|
constructor(code, message) {
|
|
@@ -27,28 +32,47 @@ export class SyncError extends Error {
|
|
|
27
32
|
}
|
|
28
33
|
}
|
|
29
34
|
|
|
30
|
-
// mode: file = whole file, block = the marked block inside a file the repository also writes to
|
|
31
|
-
//
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
{ path: '
|
|
39
|
-
{ path: '
|
|
40
|
-
{ path: '.starciwork/.gitignore', template: { be: 'be/starciwork.gitignore' }, mode: 'file', header: false },
|
|
35
|
+
// mode: file = whole file, block = the marked block inside a file the repository also writes to, scripts = the `scripts`
|
|
36
|
+
// block of package.json. A template that can hold a comment opens with {{header}}; a file whose exact content is
|
|
37
|
+
// specified (JSON, the eslint one-liner) has no header.
|
|
38
|
+
const MODE_OF_GROUP = Object.freeze({ 'package-scripts': 'scripts' });
|
|
39
|
+
const LITERAL_FILE = /^[^*?<>{}[\]]+[^/*?<>{}[\]]$/;
|
|
40
|
+
|
|
41
|
+
/** The targets no managedBy slot lists as a whole file (see the header of this module). */
|
|
42
|
+
export const UNLISTED_TARGETS = Object.freeze([
|
|
43
|
+
{ path: '.gitignore', template: { be: 'be/gitignore', fe: 'fe/gitignore' }, mode: 'block' },
|
|
44
|
+
{ path: '.starciwork/.gitignore', template: { be: 'be/starciwork.gitignore' }, mode: 'file' },
|
|
41
45
|
].map(Object.freeze));
|
|
42
46
|
|
|
47
|
+
const templateName = (profile, group, file) => `${profile}/${group}/${file.split('/').map(segment => segment.replace(/^\./, '')).join('/')}`;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Every target of `profile`: the files the manifest's managedBy slots list, then the unlisted ones. A slot that names
|
|
51
|
+
* managedBy lists literal files only, and each has a template under its profile or under common.
|
|
52
|
+
*/
|
|
53
|
+
export function targetsOf(profile, { manifest = loadSlotManifest(), readTemplate = readBundled, hasTemplate = name => fs.existsSync(path.join(TEMPLATES_DIR, name)) } = {}) {
|
|
54
|
+
const listed = [];
|
|
55
|
+
for (const slot of manifest.slots) {
|
|
56
|
+
if (slot.managedBy === undefined || !slot.profiles.includes(profile)) continue;
|
|
57
|
+
for (const file of braceVariants(slot.path)) {
|
|
58
|
+
if (!LITERAL_FILE.test(file)) throw new SyncError('HFS_SYNC_MANIFEST_MANAGED', `slot ${slot.id} names managedBy but its path ${slot.path} is not a list of literal files (${file})`);
|
|
59
|
+
const template = [profile, 'common'].map(dir => templateName(dir, slot.managedBy, file)).find(hasTemplate);
|
|
60
|
+
if (!template) throw new SyncError('HFS_SYNC_TEMPLATE_MISSING', `slot ${slot.id} manages ${file} for ${profile}, but templates/{${profile},common}/${slot.managedBy} has no template for it`);
|
|
61
|
+
listed.push({ path: file, template, mode: MODE_OF_GROUP[slot.managedBy] ?? 'file', slot: slot.id });
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return [...listed, ...UNLISTED_TARGETS.filter(target => target.template[profile]).map(target => ({ path: target.path, template: target.template[profile], mode: target.mode }))];
|
|
65
|
+
}
|
|
66
|
+
|
|
43
67
|
const KEBAB = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
|
44
68
|
const lf = text => text.replace(/\r\n/g, '\n');
|
|
45
69
|
export const hashOf = text => createHash('sha256').update(lf(text)).digest('hex');
|
|
46
70
|
|
|
47
|
-
/** The hfs.json shape sync reads:
|
|
48
|
-
export function validateHfs(hfs) {
|
|
71
|
+
/** The hfs.json shape sync reads: the manifest major, profile, project and a non-empty apps list (a back end names each app's kind). */
|
|
72
|
+
export function validateHfs(hfs, manifest = loadSlotManifest()) {
|
|
49
73
|
const bad = message => { throw new SyncError('HFS_SYNC_HFS_INVALID', message); };
|
|
50
74
|
if (!hfs || typeof hfs !== 'object') bad('hfs.json is not an object');
|
|
51
|
-
if (hfs.hfs !==
|
|
75
|
+
if (hfs.hfs !== manifest.major) bad(`hfs must be ${manifest.major}, the major of the slot manifest`);
|
|
52
76
|
if (!PROFILES.includes(hfs.profile)) bad(`profile must be one of ${PROFILES.join(', ')}`);
|
|
53
77
|
if (typeof hfs.project !== 'string' || !KEBAB.test(hfs.project)) bad('project must be a kebab-case name');
|
|
54
78
|
if (!Array.isArray(hfs.apps) || hfs.apps.length === 0) bad('apps must list at least one app');
|
|
@@ -60,8 +84,16 @@ export function validateHfs(hfs) {
|
|
|
60
84
|
for (const app of hfs.apps) {
|
|
61
85
|
if (!app || typeof app.name !== 'string' || !KEBAB.test(app.name)) bad('every app needs a kebab-case name');
|
|
62
86
|
if (seen.has(app.name)) bad(`app ${app.name} is listed twice`);
|
|
87
|
+
if (hfs.profile === 'be' && !manifest.appKinds.be.includes(app.kind)) bad(`app ${app.name} needs a kind, one of ${manifest.appKinds.be.join(', ')}`);
|
|
63
88
|
seen.add(app.name);
|
|
64
89
|
}
|
|
90
|
+
// The whole declaration must be valid for the manifest it pins (connections, optional slots, ...): sync never renders
|
|
91
|
+
// configs for a declaration the eslint canons and hfs check would refuse, so a pin bump cannot leave lint unable to start.
|
|
92
|
+
try {
|
|
93
|
+
resolveRepoDeclaration(manifest, hfs);
|
|
94
|
+
} catch (error) {
|
|
95
|
+
bad(`hfs.json is not a valid declaration for manifest ${manifest.version}: ${error.message}`);
|
|
96
|
+
}
|
|
65
97
|
return hfs;
|
|
66
98
|
}
|
|
67
99
|
|
|
@@ -78,9 +110,10 @@ export function render(text, vars, readTemplate = readBundled) {
|
|
|
78
110
|
});
|
|
79
111
|
}
|
|
80
112
|
|
|
81
|
-
/**
|
|
113
|
+
/** The Sonar exclusions from the jest preset a back end installs: { sonarExclusions }; a front end has no test runner and no preset: null. */
|
|
82
114
|
export async function loadPresets(root, profile) {
|
|
83
|
-
|
|
115
|
+
if (profile === 'fe') return null;
|
|
116
|
+
const name = '@starci/jest-preset';
|
|
84
117
|
const require = createRequire(path.join(root, 'package.json'));
|
|
85
118
|
let resolved;
|
|
86
119
|
try {
|
|
@@ -88,40 +121,87 @@ export async function loadPresets(root, profile) {
|
|
|
88
121
|
} catch {
|
|
89
122
|
throw new SyncError('HFS_SYNC_PRESET_MISSING', `${name} is not installed under ${root}; set it to the exact version in knowledge/hfs/canon-pins.yaml and reinstall`);
|
|
90
123
|
}
|
|
91
|
-
const preset =
|
|
92
|
-
return { sonarExclusions: preset.sonarExclusions()
|
|
124
|
+
const preset = require(resolved);
|
|
125
|
+
return { sonarExclusions: preset.sonarExclusions() };
|
|
93
126
|
}
|
|
94
127
|
|
|
128
|
+
/**
|
|
129
|
+
* The package.json script lines the apps add, each ending with a comma (the template puts them mid-object). A back end:
|
|
130
|
+
* `start:<app>` runs a built api, worker or cli app, `migrate` the migrate app. A front end: `dev:<app>` and `start:<app>`
|
|
131
|
+
* run the app's own script through its workspace path (the npm workspace of apps/<app>).
|
|
132
|
+
*/
|
|
133
|
+
export function appScripts(profile, apps) {
|
|
134
|
+
const line = (name, command) => `${JSON.stringify(name)}: ${JSON.stringify(command)},`;
|
|
135
|
+
if (profile === 'fe') return apps.flatMap(app => [line(`dev:${app.name}`, `npm run dev --workspace apps/${app.name}`), line(`start:${app.name}`, `npm run start --workspace apps/${app.name}`)]).join('\n ');
|
|
136
|
+
const migrates = apps.filter(app => app.kind === 'migrate');
|
|
137
|
+
return apps.map(app => {
|
|
138
|
+
const name = app.kind === 'migrate' ? (migrates.length === 1 ? 'migrate' : `migrate:${app.name}`) : `start:${app.name}`;
|
|
139
|
+
return line(name, `node dist/apps/${app.name}/src/main.js`);
|
|
140
|
+
}).join('\n ');
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** True when hfs.json opts into a workspace package (repo.packages or any fe.package.* slot): the sources then include packages/. */
|
|
144
|
+
export const opensPackages = hfs => (hfs.optionalSlots ?? []).some(id => id === 'repo.packages' || String(id).startsWith('fe.package.'));
|
|
145
|
+
|
|
146
|
+
/** The stylesheets of a front end the CSS canon judges: every app and every workspace package (slot fe.route, fe.package.*). */
|
|
147
|
+
export const STYLE_GLOB = '{apps,packages}/*/src/**/*.css';
|
|
148
|
+
|
|
95
149
|
/** Every value a template can name, derived from hfs.json and the presets. */
|
|
96
150
|
export function variables(hfs, presets, sonarKey) {
|
|
97
|
-
const
|
|
151
|
+
const packages = hfs.profile === 'fe' && opensPackages(hfs);
|
|
98
152
|
return {
|
|
153
|
+
header: HEADER(hfs.profile),
|
|
154
|
+
appScripts: appScripts(hfs.profile, hfs.apps),
|
|
99
155
|
profile: hfs.profile,
|
|
100
156
|
nodeMajor: String(NODE_MAJOR),
|
|
101
157
|
sonarKey: sonarKey ?? `${hfs.project}-${hfs.profile === 'be' ? 'backend' : 'fe'}`,
|
|
102
|
-
sonarExclusions: presets
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
158
|
+
sonarExclusions: presets?.sonarExclusions ?? '',
|
|
159
|
+
tsconfigPaths: [...hfs.apps.map(app => `apps/${app.name}/tsconfig.json`), ...(packages ? ['packages/*/tsconfig.json'] : [])].join(','),
|
|
160
|
+
sonarRoots: packages ? 'apps,packages' : 'apps',
|
|
161
|
+
styleGlob: STYLE_GLOB,
|
|
106
162
|
};
|
|
107
163
|
}
|
|
108
164
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
165
|
+
// The scripts of a package.json as one comparable text: a line per script, sorted by name (order is not drift).
|
|
166
|
+
const scriptsText = scripts => `${Object.keys(scripts).sort().map(name => `${name}: ${typeof scripts[name] === 'string' ? scripts[name] : JSON.stringify(scripts[name])}`).join('\n')}\n`;
|
|
167
|
+
|
|
168
|
+
function parseScripts(text) {
|
|
169
|
+
try {
|
|
170
|
+
const { scripts } = JSON.parse(text);
|
|
171
|
+
return scripts && typeof scripts === 'object' && !Array.isArray(scripts) ? scripts : null;
|
|
172
|
+
} catch {
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* The managed content of every target this profile owns: [{ path, mode, content, hash }] (a scripts target also carries
|
|
179
|
+
* `scripts`, the parsed block). `sonarKey` is the key the repository's stack declaration names; without one the key is
|
|
180
|
+
* derived from hfs.json.
|
|
181
|
+
*/
|
|
182
|
+
export function renderTargets(hfs, presets, { sonarKey, readTemplate = readBundled, manifest = loadSlotManifest() } = {}) {
|
|
183
|
+
validateHfs(hfs, manifest);
|
|
113
184
|
const vars = variables(hfs, presets, sonarKey);
|
|
114
|
-
return
|
|
115
|
-
const body = render(readTemplate(target.template
|
|
116
|
-
|
|
117
|
-
|
|
185
|
+
return targetsOf(hfs.profile, { manifest, readTemplate, hasTemplate: name => { try { return readTemplate(name) !== undefined; } catch { return false; } } }).map(target => {
|
|
186
|
+
const body = render(readTemplate(target.template), vars, readTemplate);
|
|
187
|
+
if (target.mode === 'scripts') {
|
|
188
|
+
const scripts = parseScripts(body);
|
|
189
|
+
if (!scripts) throw new SyncError('HFS_SYNC_TEMPLATE_VARIABLE', `the rendered ${target.template} has no scripts object`);
|
|
190
|
+
const content = scriptsText(scripts);
|
|
191
|
+
return { path: target.path, mode: 'scripts', content, hash: hashOf(content), scripts };
|
|
192
|
+
}
|
|
193
|
+
const managed = target.mode === 'block' ? `${BLOCK_BEGIN}\n${body.replace(/\n*$/, '\n')}${BLOCK_END}\n` : body;
|
|
118
194
|
return { path: target.path, mode: target.mode, content: managed, hash: hashOf(managed) };
|
|
119
195
|
});
|
|
120
196
|
}
|
|
121
197
|
|
|
122
|
-
// The part of a file sync manages: all of it, or the
|
|
198
|
+
// The part of a file sync manages: all of it, the marked block, or the scripts block as text.
|
|
123
199
|
function managedPart(mode, text) {
|
|
124
200
|
if (mode === 'file') return text;
|
|
201
|
+
if (mode === 'scripts') {
|
|
202
|
+
const scripts = parseScripts(text);
|
|
203
|
+
return scripts ? scriptsText(scripts) : null;
|
|
204
|
+
}
|
|
125
205
|
const start = text.indexOf(BLOCK_BEGIN);
|
|
126
206
|
const end = text.indexOf(BLOCK_END);
|
|
127
207
|
if (start < 0 || end < start) return null;
|
|
@@ -159,7 +239,13 @@ export function writeTargets(root, targets) {
|
|
|
159
239
|
const file = path.join(root, target.path);
|
|
160
240
|
const existing = fs.existsSync(file) ? lf(fs.readFileSync(file, 'utf8')) : '';
|
|
161
241
|
let next = target.content;
|
|
162
|
-
if (target.mode === '
|
|
242
|
+
if (target.mode === 'scripts') {
|
|
243
|
+
let pkg = {};
|
|
244
|
+
if (existing) {
|
|
245
|
+
try { pkg = JSON.parse(existing); } catch { throw new SyncError('HFS_SYNC_HFS_INVALID', `${target.path} is not valid JSON; fix it by hand before sync can set its scripts`); }
|
|
246
|
+
}
|
|
247
|
+
next = `${JSON.stringify({ ...pkg, scripts: target.scripts }, null, 2)}\n`;
|
|
248
|
+
} else if (target.mode === 'block' && existing) {
|
|
163
249
|
const current = managedPart('block', existing);
|
|
164
250
|
next = current === null ? `${target.content}\n${existing}` : existing.replace(current, () => target.content);
|
|
165
251
|
}
|
|
@@ -181,6 +267,13 @@ export function loadHfs(root) {
|
|
|
181
267
|
}
|
|
182
268
|
}
|
|
183
269
|
|
|
270
|
+
/** The rendered targets of the repository at `root`: its hfs.json, the Sonar key its stack declaration names, the presets it installs. */
|
|
271
|
+
export async function renderRepo(root, { presets, parseYaml } = {}) {
|
|
272
|
+
const hfs = loadHfs(root);
|
|
273
|
+
const sonarKey = await readDeclaredSonarKey(root, { parseYaml, stacks: hfs.stacks, fail: message => { throw new SyncError('HFS_SYNC_SONAR_KEY', message); } });
|
|
274
|
+
return { hfs, targets: renderTargets(hfs, presets ?? await loadPresets(root, hfs.profile), { sonarKey }) };
|
|
275
|
+
}
|
|
276
|
+
|
|
184
277
|
/** `hfs sync --check | --write [--root <dir>]`; returns the exit code (0 clean, 1 drift or error, 2 usage). */
|
|
185
278
|
export async function runSync(argv, { cwd = process.cwd(), out = line => process.stdout.write(`${line}\n`), presets, parseYaml } = {}) {
|
|
186
279
|
const check = argv.includes('--check'), write = argv.includes('--write'), init = argv.includes('--init');
|
|
@@ -199,8 +292,7 @@ export async function runSync(argv, { cwd = process.cwd(), out = line => process
|
|
|
199
292
|
out(`hfs sync --init: ${created.length} created, ${skipped.length} already exist and were left alone`);
|
|
200
293
|
return 0;
|
|
201
294
|
}
|
|
202
|
-
const
|
|
203
|
-
const targets = renderTargets(hfs, presets ?? await loadPresets(root, hfs.profile), { sonarKey });
|
|
295
|
+
const { targets } = await renderRepo(root, { presets, parseYaml });
|
|
204
296
|
if (write) {
|
|
205
297
|
const written = writeTargets(root, targets);
|
|
206
298
|
for (const file of written) out(`wrote ${file}`);
|
package/sync/managed.mjs
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
// The managed-file findings of `hfs check` (BE-CONVENTION 1.17, 3.1 principle 4): the rendered set of `hfs sync`, compared
|
|
2
|
+
// with the tracked repository, and the tool configuration nothing may add to it.
|
|
3
|
+
// HFS_MANAGED_FILE_DRIFT (R05) a managed file that exists but differs from its render: tsconfig*.json (but see R22),
|
|
4
|
+
// jest.config.js (back end), .prettierrc, .prettierignore, husky hooks, both workflows and sonar
|
|
5
|
+
// files, and the `scripts` block of package.json (compared as parsed JSON, so key order is
|
|
6
|
+
// not drift). A file that is absent is the slot manifest's finding (HFS_SLOT_REQUIRED_MISSING);
|
|
7
|
+
// the `.gitignore` block is R04's.
|
|
8
|
+
// HFS_GITIGNORE_BLOCK_DRIFT (R04)
|
|
9
|
+
// the managed block of `.gitignore` differs from the rendered block, or is not there; the
|
|
10
|
+
// repository's own lines around the block are not judged.
|
|
11
|
+
// HFS_RULE_OFF_WITHOUT_REPLACEMENT (R17)
|
|
12
|
+
// eslint.config.mjs (either profile) or stylelint.config.mjs (front end) differs from its render.
|
|
13
|
+
// The render is the one-liner that calls the canon factory, and the factory takes no override, so a rule can be off, warned or redefined
|
|
14
|
+
// in a repository only through a difference from the render: R17's proof is the comparison.
|
|
15
|
+
// The file is judged here and not under R05, so one edit is one finding.
|
|
16
|
+
// HFS_TOOL_CONFIG_LOCAL (R16) what the render does not cover and could still weaken the canon: a tracked file that defines
|
|
17
|
+
// an ESLint rule (`meta.type` plus `create(context)`) or a stylelint plugin (`createPlugin`), a script or
|
|
18
|
+
// nested package.json that runs eslint, stylelint or prettier with a flag that swaps or switches off the
|
|
19
|
+
// configuration, and a package.json that carries a tool configuration key (`eslintConfig`, `stylelint`,
|
|
20
|
+
// `prettier`, `lint-staged`, `jest`). Forbidden tool-config files (.eslintrc*, .eslintignore, a second
|
|
21
|
+
// eslint.config.*) are refused by hfs-check through the slot manifest under the same code.
|
|
22
|
+
// HFS_SONAR_CONFIG (R11) sonar-project.properties differs from its render (the render names no host URL, sources and tests that
|
|
23
|
+
// do not overlap, the `sonar.exclusions` of the jest preset (a back end), no coverage import, the ESLint report and the
|
|
24
|
+
// HFS import files Sonar reads), or the stack declaration names a quality gate other than the one
|
|
25
|
+
// gate of knowledge/sonar-gate.yaml (bundled in the runtime copy). One edit is one finding.
|
|
26
|
+
// HFS_TS_STRICT (R22) the root tsconfig.json drift (either profile), named by flag (ts-strict.mjs) instead of by hash.
|
|
27
|
+
import fs from 'node:fs';
|
|
28
|
+
import path from 'node:path';
|
|
29
|
+
import { SyncError, checkTargets, renderRepo } from './index.mjs';
|
|
30
|
+
import { parseYaml as bundledParseYaml } from '../runtime/engine/yaml.mjs';
|
|
31
|
+
import { readDeclaredSonarGate } from './sonar-key.mjs';
|
|
32
|
+
import { TS_STRICT_FILE, tsStrictFindings } from './ts-strict.mjs';
|
|
33
|
+
|
|
34
|
+
// The managed one-liners whose difference from the render is the proof that no rule is off, warned or redefined (R17).
|
|
35
|
+
const ONE_LINER_FILES = new Set(['eslint.config.mjs', 'stylelint.config.mjs']);
|
|
36
|
+
const SONAR_PROPERTIES_FILE = 'sonar-project.properties';
|
|
37
|
+
const SONAR_GATE_FILE = new URL('../runtime/knowledge/sonar-gate.yaml', import.meta.url);
|
|
38
|
+
const MAX_SCANNED_BYTES = 1024 * 1024;
|
|
39
|
+
const RULE_FILE = /\.[cm]?[jt]s$/;
|
|
40
|
+
// The places a command line can still live: the repository's scripts (repo.scripts) and its nested package.json files. The
|
|
41
|
+
// root package.json, the hooks and the workflows are managed files, and no other hook, workflow or shell script has a slot.
|
|
42
|
+
const SCRIPT_FILE = /^scripts\/[^/]+\.mjs$/;
|
|
43
|
+
// An ESLint rule module: `meta` with an ESLint rule type, and a `create` that takes the rule context.
|
|
44
|
+
const RULE_META = /\bmeta\s*:\s*\{[^}]*\btype\s*:\s*["'`](?:problem|suggestion|layout)["'`]/s;
|
|
45
|
+
const RULE_CREATE = /\bcreate\s*(?:\(|:\s*(?:async\s+)?(?:function\s*)?\()\s*\w+/;
|
|
46
|
+
// A stylelint plugin: `stylelint.createPlugin(ruleName, rule)` in a file that imports stylelint, the only way a repository adds a CSS rule.
|
|
47
|
+
const STYLELINT_CREATE_PLUGIN = /\bcreatePlugin\s*\(/;
|
|
48
|
+
const STYLELINT_IMPORT = /["']stylelint["']/;
|
|
49
|
+
// Keys of package.json that are a second home for a tool's configuration.
|
|
50
|
+
const TOOL_CONFIG_KEYS = ['eslintConfig', 'stylelint', 'prettier', 'lint-staged', 'jest'];
|
|
51
|
+
// Flags that swap the configuration or switch a rule or a directive off. Only a line that runs the tool is judged.
|
|
52
|
+
const TOOL_FLAGS = [
|
|
53
|
+
{ tool: /\beslint\b/, flag: /(?<![\w-])(--rule|--no-inline-config|--no-eslintrc|--no-config-lookup|--config|-c|--ignore-pattern|--ignore-path|--no-ignore|--rulesdir|--plugin|--parser|--parser-options|--env|--global)(?![\w-])/ },
|
|
54
|
+
{ tool: /\bstylelint\b/, flag: /(?<![\w-])(--config|--config-basedir|--ignore-path|--ignore-pattern|--custom-syntax|--disable-default-ignores)(?![\w-])/ },
|
|
55
|
+
{ tool: /\bprettier\b/, flag: /(?<![\w-])(--config|--no-config|--ignore-path|--no-editorconfig|--plugin)(?![\w-])/ },
|
|
56
|
+
];
|
|
57
|
+
|
|
58
|
+
const readText = (root, file) => {
|
|
59
|
+
try {
|
|
60
|
+
const target = path.join(root, file);
|
|
61
|
+
return fs.statSync(target).size > MAX_SCANNED_BYTES ? null : fs.readFileSync(target, 'utf8');
|
|
62
|
+
} catch {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const local = (file, message) => ({ code: 'HFS_TOOL_CONFIG_LOCAL', level: 'error', path: file, message });
|
|
68
|
+
|
|
69
|
+
/** Every managed target that exists on disk but differs from its render, as one finding each. */
|
|
70
|
+
function driftFindings(repoRoot, targets) {
|
|
71
|
+
const findings = [];
|
|
72
|
+
for (const result of checkTargets(repoRoot, targets)) {
|
|
73
|
+
const target = targets.find(candidate => candidate.path === result.path);
|
|
74
|
+
if (!fs.existsSync(path.join(repoRoot, result.path)) || result.status === 'ok') continue;
|
|
75
|
+
if (result.path === TS_STRICT_FILE) {
|
|
76
|
+
const flags = tsStrictFindings(fs.readFileSync(path.join(repoRoot, result.path), 'utf8'), target.content);
|
|
77
|
+
if (flags.length) {
|
|
78
|
+
findings.push(...flags);
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
const code = target.mode === 'block' ? 'HFS_GITIGNORE_BLOCK_DRIFT' : ONE_LINER_FILES.has(result.path) ? 'HFS_RULE_OFF_WITHOUT_REPLACEMENT' : result.path === SONAR_PROPERTIES_FILE ? 'HFS_SONAR_CONFIG' : 'HFS_MANAGED_FILE_DRIFT';
|
|
83
|
+
const where = result.difference ? `; line ${result.difference.line} expected ${JSON.stringify(result.difference.expected)}, found ${JSON.stringify(result.difference.actual)}` : '';
|
|
84
|
+
const what = target.mode === 'block' ? `the managed block of ${result.path} is ${result.status === 'missing' ? 'missing' : 'not its render'}` : target.mode === 'scripts' ? 'the scripts block of package.json is not the rendered one' : `${result.path} is not its render${code === 'HFS_RULE_OFF_WITHOUT_REPLACEMENT' ? ', so a rule can be off or redefined in it' : ''}`;
|
|
85
|
+
findings.push({ code, level: 'error', path: result.path, mode: target.mode, expectedHash: result.expectedHash, ...(result.actualHash ? { actualHash: result.actualHash } : {}), message: `${what} (expected sha256 ${result.expectedHash.slice(0, 12)}${result.actualHash ? `, found ${result.actualHash.slice(0, 12)}` : ''}${where}); run "npx hfs sync --write"` });
|
|
86
|
+
}
|
|
87
|
+
return findings;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** R11: the quality gate the stack declaration names is the one gate of the bundled knowledge/sonar-gate.yaml. */
|
|
91
|
+
function sonarGateFindings(repoRoot, hfs, parseYaml) {
|
|
92
|
+
const declared = readDeclaredSonarGate(repoRoot, { parseYaml, stacks: hfs.stacks });
|
|
93
|
+
if (declared === null) return [];
|
|
94
|
+
const gate = (parseYaml ?? bundledParseYaml)(fs.readFileSync(SONAR_GATE_FILE, 'utf8'))?.gate?.name;
|
|
95
|
+
if (declared.qualityGate === gate) return [];
|
|
96
|
+
return [{ code: 'HFS_SONAR_CONFIG', level: 'error', path: declared.file, message: `${declared.file} services.sonar.qualityGate is ${declared.qualityGate ?? 'absent'}; every product names the one gate ${gate} of knowledge/sonar-gate.yaml and states no threshold of its own` }];
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function ruleFileFindings(repoRoot, tracked) {
|
|
100
|
+
const findings = [];
|
|
101
|
+
for (const file of tracked.filter(candidate => RULE_FILE.test(candidate))) {
|
|
102
|
+
const text = readText(repoRoot, file);
|
|
103
|
+
if (text === null) continue;
|
|
104
|
+
if (RULE_META.test(text) && RULE_CREATE.test(text)) findings.push(local(file, `${file} defines an ESLint rule; a repository defines no rule, the canon plugin is the only source`));
|
|
105
|
+
else if (STYLELINT_CREATE_PLUGIN.test(text) && STYLELINT_IMPORT.test(text)) findings.push(local(file, `${file} defines a stylelint plugin; a repository defines no rule, @starci/stylelint-canon is the only source`));
|
|
106
|
+
}
|
|
107
|
+
return findings;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** A package.json that keeps a tool's configuration under a key: a second source beside the managed file. */
|
|
111
|
+
function toolKeyFindings(repoRoot, tracked) {
|
|
112
|
+
const findings = [];
|
|
113
|
+
for (const file of tracked.filter(candidate => candidate === 'package.json' || candidate.endsWith('/package.json'))) {
|
|
114
|
+
const text = readText(repoRoot, file);
|
|
115
|
+
if (text === null) continue;
|
|
116
|
+
let manifest;
|
|
117
|
+
try { manifest = JSON.parse(text); } catch { continue; }
|
|
118
|
+
for (const key of TOOL_CONFIG_KEYS) {
|
|
119
|
+
if (manifest && typeof manifest === 'object' && Object.hasOwn(manifest, key)) findings.push(local(file, `${file} carries a "${key}" configuration; the tool's configuration is the managed file and nothing else`));
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return findings;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function flagFindings(repoRoot, tracked, managed) {
|
|
126
|
+
const findings = [];
|
|
127
|
+
const commandLines = (file, text) => {
|
|
128
|
+
if (file.endsWith('package.json')) {
|
|
129
|
+
try {
|
|
130
|
+
return Object.entries(JSON.parse(text).scripts ?? {}).map(([name, command]) => [`script ${name}`, String(command)]);
|
|
131
|
+
} catch {
|
|
132
|
+
return [];
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return text.split('\n').map((line, index) => [`line ${index + 1}`, line]);
|
|
136
|
+
};
|
|
137
|
+
for (const file of tracked.filter(candidate => !managed.has(candidate) && (SCRIPT_FILE.test(candidate) || candidate.endsWith('/package.json')))) {
|
|
138
|
+
const text = readText(repoRoot, file);
|
|
139
|
+
if (text === null) continue;
|
|
140
|
+
for (const [where, line] of commandLines(file, text)) {
|
|
141
|
+
for (const { tool, flag } of TOOL_FLAGS) {
|
|
142
|
+
const hit = tool.test(line) && flag.exec(line);
|
|
143
|
+
if (hit) findings.push(local(file, `${file} (${where}) runs ${tool.source.replace(/\\b/g, '')} with ${hit[1]}, which swaps or switches off the canon configuration; run the tool with no such flag`));
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return findings;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* The managed-file findings of the repository at `repoRoot` whose tracked paths are `tracked`. A repository whose hfs.json
|
|
152
|
+
* is unreadable has none: the slot check reports the declaration. A missing preset or an unreadable stack declaration is
|
|
153
|
+
* a SyncError the caller reports as a refusal.
|
|
154
|
+
*/
|
|
155
|
+
export async function managedFindings({ repoRoot, tracked, presets, parseYaml }) {
|
|
156
|
+
let rendered;
|
|
157
|
+
try {
|
|
158
|
+
rendered = await renderRepo(repoRoot, { presets, parseYaml });
|
|
159
|
+
} catch (error) {
|
|
160
|
+
if (error instanceof SyncError && error.code === 'HFS_SYNC_HFS_INVALID') return [];
|
|
161
|
+
throw error;
|
|
162
|
+
}
|
|
163
|
+
const { hfs, targets } = rendered;
|
|
164
|
+
const findings = driftFindings(repoRoot, targets);
|
|
165
|
+
findings.push(...sonarGateFindings(repoRoot, hfs, parseYaml));
|
|
166
|
+
findings.push(...ruleFileFindings(repoRoot, tracked));
|
|
167
|
+
findings.push(...toolKeyFindings(repoRoot, tracked));
|
|
168
|
+
findings.push(...flagFindings(repoRoot, tracked, new Set(targets.map(target => target.path))));
|
|
169
|
+
return findings;
|
|
170
|
+
}
|
package/sync/skeleton.mjs
CHANGED
|
@@ -2,11 +2,20 @@
|
|
|
2
2
|
// endpoint; for a front end the next-intl [locale] shell with vi default, as-needed prefix and proxy.ts).
|
|
3
3
|
// Unlike the managed files it is written once and never overwritten: an existing file is skipped, so re-running --init
|
|
4
4
|
// on a repository that already grew its own source changes nothing.
|
|
5
|
+
//
|
|
6
|
+
// A front end's skeleton is the templates/fe/skeleton tree (the app shell, common to every repository) plus ONE of two
|
|
7
|
+
// trees for what a repository writes once: with one app the app keeps its own i18n stack and its own API client
|
|
8
|
+
// (templates/fe/skeleton-app); with two or more apps the stack and the client are written once, as the packages
|
|
9
|
+
// `packages/<project>-i18n` and `packages/<project>-api`, and each app keeps a thin adapter over them
|
|
10
|
+
// (templates/fe/skeleton-shared). The choice is the number of apps in hfs.json, nothing else.
|
|
5
11
|
import fs from 'node:fs';
|
|
6
12
|
import path from 'node:path';
|
|
7
13
|
import { TEMPLATES_DIR, SyncError, render, validateHfs } from './index.mjs';
|
|
8
14
|
|
|
9
15
|
const APP_DIR = '__app__';
|
|
16
|
+
const FAMILY_DIR = '__family__';
|
|
17
|
+
/** The slots a multi-app front end must opt into in hfs.json before its shared packages are written. */
|
|
18
|
+
export const SHARED_PACKAGE_SLOTS = Object.freeze(['fe.package.i18n', 'fe.package.api']);
|
|
10
19
|
const pascal = name => name.split('-').map(part => part[0].toUpperCase() + part.slice(1)).join('');
|
|
11
20
|
|
|
12
21
|
function listFiles(dir, base = dir) {
|
|
@@ -19,19 +28,32 @@ function listFiles(dir, base = dir) {
|
|
|
19
28
|
/** The apps that get a skeleton: a back end's `api` apps, every front-end app. */
|
|
20
29
|
export const skeletonApps = hfs => hfs.apps.filter(app => hfs.profile === 'fe' || app.kind === 'api');
|
|
21
30
|
|
|
31
|
+
/** True when the repository writes its i18n stack and API client once, as packages: a front end with two or more apps. */
|
|
32
|
+
export const sharesPackages = hfs => hfs.profile === 'fe' && hfs.apps.length > 1;
|
|
33
|
+
|
|
34
|
+
/** The template directories of a repository, in order: the profile's skeleton, then (front end) the one-app or the shared-package tree. */
|
|
35
|
+
export const skeletonDirs = hfs => [path.join(TEMPLATES_DIR, hfs.profile, 'skeleton'), ...(hfs.profile === 'fe' ? [path.join(TEMPLATES_DIR, 'fe', sharesPackages(hfs) ? 'skeleton-shared' : 'skeleton-app')] : [])];
|
|
36
|
+
|
|
22
37
|
/** Every skeleton file for this repository: [{ path, content }], the shared ones once and the per-app ones per app. */
|
|
23
|
-
export function skeletonFiles(hfs,
|
|
38
|
+
export function skeletonFiles(hfs, dirs = skeletonDirs(hfs)) {
|
|
24
39
|
validateHfs(hfs);
|
|
25
|
-
if (
|
|
40
|
+
if (sharesPackages(hfs)) {
|
|
41
|
+
const missing = SHARED_PACKAGE_SLOTS.filter(id => !(hfs.optionalSlots ?? []).includes(id));
|
|
42
|
+
if (missing.length) throw new SyncError('HFS_SYNC_HFS_INVALID', `a front end with ${hfs.apps.length} apps writes its i18n stack and its API client once, as packages: list ${missing.join(' and ')} in hfs.json optionalSlots before hfs sync --init`);
|
|
43
|
+
}
|
|
26
44
|
const files = [];
|
|
27
|
-
for (const
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
45
|
+
for (const dir of dirs) {
|
|
46
|
+
if (!fs.existsSync(dir)) throw new SyncError('HFS_SYNC_SKELETON_MISSING', `no skeleton templates in ${path.relative(TEMPLATES_DIR, dir)}`);
|
|
47
|
+
for (const rel of listFiles(dir)) {
|
|
48
|
+
const source = fs.readFileSync(path.join(dir, rel), 'utf8').replace(/\r\n/g, '\n');
|
|
49
|
+
const once = { family: hfs.project, project: hfs.project };
|
|
50
|
+
if (!rel.includes(APP_DIR)) {
|
|
51
|
+
files.push({ path: rel.split(FAMILY_DIR).join(hfs.project), content: render(source, once) });
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
for (const app of skeletonApps(hfs)) {
|
|
55
|
+
files.push({ path: rel.split(APP_DIR).join(app.name), content: render(source, { ...once, app: app.name, appPascal: pascal(app.name) }) });
|
|
56
|
+
}
|
|
35
57
|
}
|
|
36
58
|
}
|
|
37
59
|
return files;
|
package/sync/sonar-key.mjs
CHANGED
|
@@ -43,3 +43,16 @@ export async function readDeclaredSonarKey(root, { parseYaml, fail, stacks }) {
|
|
|
43
43
|
if (keys.length > 1) fail(`${DECLARATION} declares ${keys.length} Sonar projects for ${repositoryName(root)} (${keys.join(', ')}); one repository has one key`);
|
|
44
44
|
return keys[0] ?? null;
|
|
45
45
|
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The quality gate the declaration names for Sonar: `{ file, qualityGate }`, or null when there is no declaration, Sonar is
|
|
49
|
+
* disabled in it, or it declares no Sonar service. The one gate is knowledge/sonar-gate.yaml; a repository names it and
|
|
50
|
+
* never states thresholds of its own.
|
|
51
|
+
*/
|
|
52
|
+
export function readDeclaredSonarGate(root, { parseYaml, stacks } = {}) {
|
|
53
|
+
const file = path.join(stacks === undefined ? root : path.resolve(root, stacks), DECLARATION);
|
|
54
|
+
if (!fs.existsSync(file)) return null;
|
|
55
|
+
const sonar = (parseYaml ?? bundledParseYaml)(fs.readFileSync(file, 'utf8'))?.services?.sonar;
|
|
56
|
+
if (!sonar || sonar.mode === 'disabled') return null;
|
|
57
|
+
return { file: path.relative(root, file).split(path.sep).join('/'), qualityGate: typeof sonar.qualityGate === 'string' ? sonar.qualityGate : null };
|
|
58
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// HFS_TS_STRICT (R22): a repository's root tsconfig.json extends the canon preset (be.json for a back end, next.json for a
|
|
2
|
+
// front end), adds `paths` when the template has them and the template's `exclude` (the world, integration, e2e and contract trees of a back end, which src/tests/tsconfig.json checks; the e2e tree of a front end),
|
|
3
|
+
// and nothing else. Every strict flag lives in @starci/tsconfig; a repository that sets, lowers or adds any compiler option, or changes the program with
|
|
4
|
+
// include/exclude/files/references, has left the preset. The expected shape is read from the rendered
|
|
5
|
+
// template (templates/<profile>/tool-config/tsconfig.json), so the preset name and the aliases exist in one place only.
|
|
6
|
+
export const TS_STRICT_FILE = 'tsconfig.json';
|
|
7
|
+
|
|
8
|
+
const same = (left, right) => JSON.stringify(left) === JSON.stringify(right);
|
|
9
|
+
const describe = (value) => (value === undefined ? 'unset' : JSON.stringify(value));
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The HFS_TS_STRICT findings of one tsconfig.json text against the rendered one: [{ code, level, path, flag, message }].
|
|
13
|
+
* `flag` names the offending key (`extends`, `paths`, a compiler option, or a top-level key).
|
|
14
|
+
*/
|
|
15
|
+
export function tsStrictFindings(actualText, expectedText, file = TS_STRICT_FILE) {
|
|
16
|
+
const expected = JSON.parse(expectedText);
|
|
17
|
+
const finding = (flag, message) => ({ code: 'HFS_TS_STRICT', level: 'error', path: file, flag, message: `${file}: ${message}` });
|
|
18
|
+
let actual;
|
|
19
|
+
try {
|
|
20
|
+
actual = JSON.parse(actualText);
|
|
21
|
+
} catch (error) {
|
|
22
|
+
return [finding('parse', `is not valid JSON (${String(error.message).split('\n')[0]}); it must be the rendered file, which extends ${expected.extends}`)];
|
|
23
|
+
}
|
|
24
|
+
if (!actual || typeof actual !== 'object' || Array.isArray(actual)) return [finding('parse', `is not a JSON object; it must extend ${expected.extends}`)];
|
|
25
|
+
const findings = [];
|
|
26
|
+
if (actual.extends !== expected.extends) findings.push(finding('extends', `extends ${describe(actual.extends)}, but the only preset is ${expected.extends}`));
|
|
27
|
+
for (const key of Object.keys(actual)) {
|
|
28
|
+
if (key === 'extends' || key === 'compilerOptions') continue;
|
|
29
|
+
if (key in expected && same(actual[key], expected[key])) continue;
|
|
30
|
+
findings.push(finding(key, key in expected ? `${key} must be ${describe(expected[key])}, found ${describe(actual[key])}` : `sets ${key}: the program is the template's, narrowed by nothing else`));
|
|
31
|
+
}
|
|
32
|
+
for (const key of Object.keys(expected)) {
|
|
33
|
+
if (key !== 'extends' && key !== 'compilerOptions' && !(key in actual)) findings.push(finding(key, `${key} is missing; it must be ${describe(expected[key])}`));
|
|
34
|
+
}
|
|
35
|
+
const options = actual.compilerOptions && typeof actual.compilerOptions === 'object' && !Array.isArray(actual.compilerOptions) ? actual.compilerOptions : {};
|
|
36
|
+
for (const [flag, value] of Object.entries(options)) {
|
|
37
|
+
if (flag === 'paths') continue;
|
|
38
|
+
findings.push(finding(flag, `${value === false ? 'lowers' : 'sets'} ${flag} (${describe(value)}); every compiler option comes from the preset`));
|
|
39
|
+
}
|
|
40
|
+
const aliases = expected.compilerOptions?.paths ?? {};
|
|
41
|
+
for (const alias of Object.keys(aliases)) {
|
|
42
|
+
if (!same(options.paths?.[alias], aliases[alias])) findings.push(finding('paths', `paths must map ${alias} to ${describe(aliases[alias])}, found ${describe(options.paths?.[alias])}`));
|
|
43
|
+
}
|
|
44
|
+
for (const alias of Object.keys(options.paths ?? {})) {
|
|
45
|
+
if (!(alias in aliases)) findings.push(finding('paths', `paths adds the alias ${alias}; the aliases of the template are the only ones`));
|
|
46
|
+
}
|
|
47
|
+
return findings;
|
|
48
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
{{header}}
|
|
1
2
|
name: ci
|
|
2
3
|
|
|
3
4
|
on:
|
|
@@ -7,7 +8,6 @@ on:
|
|
|
7
8
|
|
|
8
9
|
permissions:
|
|
9
10
|
contents: read
|
|
10
|
-
id-token: write
|
|
11
11
|
|
|
12
12
|
jobs:
|
|
13
13
|
ci:
|
|
@@ -21,30 +21,30 @@ jobs:
|
|
|
21
21
|
node-version: {{nodeMajor}}
|
|
22
22
|
cache: npm
|
|
23
23
|
- run: npm ci
|
|
24
|
-
- name: hfs sync
|
|
25
|
-
run: npx hfs sync --check
|
|
26
24
|
- name: hfs check
|
|
27
|
-
run:
|
|
25
|
+
run: npm run hfs:report
|
|
28
26
|
- name: lint
|
|
29
27
|
run: npm run lint:check
|
|
28
|
+
- name: lint report
|
|
29
|
+
if: ${{ !cancelled() }}
|
|
30
|
+
run: npm run lint:report
|
|
31
|
+
- name: eslint sonar import
|
|
32
|
+
if: ${{ !cancelled() }}
|
|
33
|
+
run: npx hfs report eslint reports/eslint.json reports/eslint.sonar.json
|
|
34
|
+
- name: format
|
|
35
|
+
run: npm run format:check
|
|
30
36
|
- name: typecheck
|
|
31
37
|
run: npm run typecheck
|
|
32
38
|
- name: unit
|
|
33
|
-
run: npm
|
|
39
|
+
run: npm test -- --ci
|
|
34
40
|
- name: build
|
|
35
41
|
run: npm run build
|
|
36
|
-
- uses: codecov/codecov-action@v5
|
|
37
|
-
with:
|
|
38
|
-
files: coverage/lcov.info
|
|
39
|
-
disable_search: true
|
|
40
|
-
fail_ci_if_error: true
|
|
41
|
-
use_oidc: true
|
|
42
42
|
- uses: SonarSource/sonarqube-scan-action@v7
|
|
43
|
-
if: env.SONAR_TOKEN != ''
|
|
43
|
+
if: ${{ !cancelled() && env.SONAR_TOKEN != '' }}
|
|
44
44
|
env:
|
|
45
45
|
SONAR_HOST_URL: ${{ vars.SONAR_HOST_URL }}
|
|
46
46
|
- uses: SonarSource/sonarqube-quality-gate-action@v1
|
|
47
|
-
if: env.SONAR_TOKEN != ''
|
|
47
|
+
if: ${{ !cancelled() && env.SONAR_TOKEN != '' }}
|
|
48
48
|
timeout-minutes: 10
|
|
49
49
|
env:
|
|
50
50
|
SONAR_HOST_URL: ${{ vars.SONAR_HOST_URL }}
|