@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
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
// hfs new service | spec - the one way a `*.service.ts` and its unit spec come into being.
|
|
2
|
+
//
|
|
3
|
+
// Unit test standard (owner-locked): only `*.service.ts` files are unit-tested, each with exactly one colocated
|
|
4
|
+
// `<name>.service.spec.ts`, built with `Test.createTestingModule({ providers }).compile()` and `moduleRef.get(Service)`,
|
|
5
|
+
// with the providers equal to the constructor dependencies and every double taken from `@starci/jest-preset`. This module
|
|
6
|
+
// writes that spec: it reads the service constructor with the repository's own TypeScript compiler API and PRE-FILLS one
|
|
7
|
+
// provider per dependency, so the author starts from a spec that already compiles, provides exactly what the service asks
|
|
8
|
+
// for and has one placeholder `it` per public method.
|
|
9
|
+
//
|
|
10
|
+
// The token of an `Inject<Name>()` decorator is `<NAME>` (UPPER_SNAKE of the name), exported from the module the decorator is
|
|
11
|
+
// imported from (the capability decorators file, through its index) - the convention the `injection-token-exported` law keeps.
|
|
12
|
+
// The double of a token comes from `ruleParams.be.specDoubles` of the slot manifest, the same table the lint law
|
|
13
|
+
// `spec-infra-double-from-kit` holds a spec to, so the skeleton satisfies the law by construction.
|
|
14
|
+
import fs from 'node:fs';
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
import { createRequire } from 'node:module';
|
|
17
|
+
import { explainPath } from '../runtime/scripts/lib/hfs-check.mjs';
|
|
18
|
+
import { loadSlotManifest } from '../runtime/scripts/lib/hfs-slots.mjs';
|
|
19
|
+
|
|
20
|
+
export class ScaffoldError extends Error {
|
|
21
|
+
constructor(code, message) {
|
|
22
|
+
super(message);
|
|
23
|
+
this.name = 'ScaffoldError';
|
|
24
|
+
this.code = code;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const KIT = '@starci/jest-preset';
|
|
29
|
+
const CLOCK_START = '2026-01-01T00:00:00.000Z';
|
|
30
|
+
const PRINT_WIDTH = 120;
|
|
31
|
+
const INDENT = ' ';
|
|
32
|
+
|
|
33
|
+
// ------------------------------------------------------------------------------------------------ names
|
|
34
|
+
|
|
35
|
+
const words = name => name.replace(/([a-z0-9])([A-Z])/g, '$1 $2').replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2').split(/[\s_-]+/).filter(Boolean);
|
|
36
|
+
/** `member-profile` -> `MemberProfile`. */
|
|
37
|
+
export const pascalOf = name => words(name).map(word => word[0].toUpperCase() + word.slice(1).toLowerCase()).join('');
|
|
38
|
+
/** `InjectPrimaryEntityManager` -> `PRIMARY_ENTITY_MANAGER`. */
|
|
39
|
+
export const tokenNameOf = decorator => words(decorator.replace(/^Inject/, '')).map(word => word.toUpperCase()).join('_');
|
|
40
|
+
const camelOf = name => { const pascal = pascalOf(name); return pascal[0].toLowerCase() + pascal.slice(1); };
|
|
41
|
+
|
|
42
|
+
const SERVICE_NAME = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
|
|
43
|
+
|
|
44
|
+
// ------------------------------------------------------------------------------------------------ reading a service
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The shape of the service class of `text`: { className, dependencies, methods }.
|
|
48
|
+
* dependencies: in constructor order, either { kind: 'token', name, decorator, token, module, type } for an `@Inject*()` custom
|
|
49
|
+
* decorator (or `@Inject(TOKEN)`), or { kind: 'class', name, className, module } for a class-typed parameter.
|
|
50
|
+
* `type` is { text, imports: [{ name, module }] }, the parameter type and where each imported name of it comes from.
|
|
51
|
+
*/
|
|
52
|
+
export function readServiceShape({ ts, fileName, text }) {
|
|
53
|
+
const source = ts.createSourceFile(fileName, text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
|
|
54
|
+
const imported = new Map();
|
|
55
|
+
for (const statement of source.statements) {
|
|
56
|
+
if (!ts.isImportDeclaration(statement) || !ts.isStringLiteral(statement.moduleSpecifier)) continue;
|
|
57
|
+
const bindings = statement.importClause?.namedBindings;
|
|
58
|
+
if (!bindings || !ts.isNamedImports(bindings)) continue;
|
|
59
|
+
for (const element of bindings.elements) imported.set(element.name.text, { module: statement.moduleSpecifier.text, imported: (element.propertyName ?? element.name).text });
|
|
60
|
+
}
|
|
61
|
+
const base = path.basename(fileName).replace(/\.service\.ts$/, '');
|
|
62
|
+
const classes = source.statements.filter(statement => ts.isClassDeclaration(statement) && statement.name && (ts.getCombinedModifierFlags(statement) & ts.ModifierFlags.Export) !== 0 && statement.name.text.endsWith('Service'));
|
|
63
|
+
const node = classes.find(candidate => candidate.name.text === `${pascalOf(base)}Service`) ?? classes[0];
|
|
64
|
+
if (!node) throw new ScaffoldError('HFS_NEW_NO_SERVICE_CLASS', `${fileName} exports no class named *Service`);
|
|
65
|
+
|
|
66
|
+
const typeOf = annotation => {
|
|
67
|
+
const names = new Set();
|
|
68
|
+
const visit = child => { if (ts.isIdentifier(child) && imported.has(child.text)) names.add(child.text); ts.forEachChild(child, visit); };
|
|
69
|
+
visit(annotation);
|
|
70
|
+
return { text: annotation.getText(source), imports: [...names].map(name => ({ name, module: imported.get(name).module })) };
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const dependencies = [];
|
|
74
|
+
const constructor = node.members.find(member => ts.isConstructorDeclaration(member));
|
|
75
|
+
for (const parameter of constructor?.parameters ?? []) {
|
|
76
|
+
if (!ts.isIdentifier(parameter.name) || !parameter.type) throw new ScaffoldError('HFS_NEW_UNTYPED_PARAMETER', `${fileName}: every constructor parameter needs a name and a type annotation`);
|
|
77
|
+
const name = parameter.name.text;
|
|
78
|
+
const decorator = (ts.getDecorators(parameter) ?? []).map(item => item.expression).find(expression => ts.isCallExpression(expression) && ts.isIdentifier(expression.expression));
|
|
79
|
+
if (decorator) {
|
|
80
|
+
const callee = decorator.expression.text;
|
|
81
|
+
if (callee === 'Inject') {
|
|
82
|
+
const argument = decorator.arguments[0];
|
|
83
|
+
if (!argument || !ts.isIdentifier(argument) || !imported.has(argument.text)) throw new ScaffoldError('HFS_NEW_TOKEN_UNKNOWN', `${fileName}: @Inject(...) of ${name} must name a token imported from a module`);
|
|
84
|
+
dependencies.push({ kind: 'token', name, decorator: 'Inject', token: argument.text, module: imported.get(argument.text).module, type: typeOf(parameter.type) });
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
if (callee.startsWith('Inject')) {
|
|
88
|
+
if (!imported.has(callee)) throw new ScaffoldError('HFS_NEW_TOKEN_UNKNOWN', `${fileName}: ${callee} is not imported, so the module that exports its token is unknown`);
|
|
89
|
+
dependencies.push({ kind: 'token', name, decorator: callee, token: tokenNameOf(callee), module: imported.get(callee).module, type: typeOf(parameter.type) });
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
const reference = ts.isTypeReferenceNode(parameter.type) && ts.isIdentifier(parameter.type.typeName) ? parameter.type.typeName.text : null;
|
|
94
|
+
if (!reference || !imported.has(reference)) throw new ScaffoldError('HFS_NEW_DEPENDENCY_UNKNOWN', `${fileName}: parameter ${name} is neither an @Inject*() token nor an imported class; provide it by hand`);
|
|
95
|
+
dependencies.push({ kind: 'class', name, className: reference, module: imported.get(reference).module });
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const methods = node.members
|
|
99
|
+
.filter(member => ts.isMethodDeclaration(member) && ts.isIdentifier(member.name) && !(ts.getCombinedModifierFlags(member) & (ts.ModifierFlags.Private | ts.ModifierFlags.Protected | ts.ModifierFlags.Static)))
|
|
100
|
+
.map(member => member.name.text);
|
|
101
|
+
return { className: node.name.text, dependencies, methods };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ------------------------------------------------------------------------------------------------ writing the spec
|
|
105
|
+
|
|
106
|
+
const tableOf = manifest => {
|
|
107
|
+
const table = manifest.ruleParams?.be?.specDoubles;
|
|
108
|
+
if (!table) throw new ScaffoldError('HFS_NEW_NO_DOUBLE_TABLE', 'the slot manifest has no ruleParams.be.specDoubles: refresh @starci/hfs');
|
|
109
|
+
return table;
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const doubleOf = (table, token) => table.doubles.find(entry => new RegExp(entry.token).test(token)) ?? table.fallback;
|
|
113
|
+
|
|
114
|
+
/** One line of code per double kind; `clock` is the shared FakeClock of the build helper. */
|
|
115
|
+
const EMITTERS = {
|
|
116
|
+
mockEntityManager: () => ({ code: 'mockEntityManager()', uses: ['mockEntityManager'] }),
|
|
117
|
+
fakeTransaction: () => ({ code: 'fakeTransaction(mockEntityManager())', uses: ['fakeTransaction', 'mockEntityManager'] }),
|
|
118
|
+
FakeClock: () => ({ code: 'clock', clock: true }),
|
|
119
|
+
recordingOutbox: () => ({ code: 'recordingOutbox()', uses: ['recordingOutbox'] }),
|
|
120
|
+
fakeCache: () => ({ code: 'fakeCache(clock)', uses: ['fakeCache'], clock: true }),
|
|
121
|
+
fakeLock: () => ({ code: 'fakeLock(clock)', uses: ['fakeLock'], clock: true }),
|
|
122
|
+
fakeIds: () => ({ code: 'fakeIds()', uses: ['fakeIds'] }),
|
|
123
|
+
builder: () => ({ code: '{}', note: 'the real values of the options this service reads: one case per flag branch' }),
|
|
124
|
+
mock: dependency => ({ code: `mock<${dependency.type.text}>()`, uses: ['mock'] }),
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
/** The import line of `names` from `module`, wrapped at the print width the way prettier would. */
|
|
128
|
+
const importLine = (names, module, typeOnly = false) => {
|
|
129
|
+
const head = typeOnly ? 'import type' : 'import';
|
|
130
|
+
const single = `${head} { ${names.join(', ')} } from "${module}"`;
|
|
131
|
+
return single.length <= PRINT_WIDTH ? single : `${head} {\n${names.map(name => `${INDENT}${name},`).join('\n')}\n} from "${module}"`;
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
const compareModules = (a, b) => {
|
|
135
|
+
const rank = module => (module.startsWith('.') ? 2 : module.startsWith('@modules/') || module.startsWith('@features/') || module.startsWith('@tests/') ? 1 : 0);
|
|
136
|
+
return rank(a) - rank(b) || a.localeCompare(b);
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
/** The spec skeleton of a service shape: text of `<name>.service.spec.ts`. */
|
|
140
|
+
export function specSkeleton({ shape, serviceFile, manifest = loadSlotManifest() }) {
|
|
141
|
+
const table = tableOf(manifest);
|
|
142
|
+
const subject = `./${path.basename(serviceFile).replace(/\.ts$/, '')}`;
|
|
143
|
+
const kit = new Set();
|
|
144
|
+
const imports = new Map();
|
|
145
|
+
const typeImports = new Map();
|
|
146
|
+
const addImport = (map, module, name) => { if (!map.has(module)) map.set(module, new Set()); map.get(module).add(name); };
|
|
147
|
+
let needsClock = false;
|
|
148
|
+
|
|
149
|
+
const taken = new Set(['service', 'clock', 'moduleRef']);
|
|
150
|
+
const variables = [];
|
|
151
|
+
const providers = [shape.className];
|
|
152
|
+
const notes = [];
|
|
153
|
+
for (const dependency of shape.dependencies) {
|
|
154
|
+
if (dependency.kind === 'class') {
|
|
155
|
+
addImport(imports, dependency.module, dependency.className);
|
|
156
|
+
const variable = uniqueName(camelOf(dependency.name), taken);
|
|
157
|
+
variables.push({ variable, code: `mock<${dependency.className}>()` });
|
|
158
|
+
kit.add('mock');
|
|
159
|
+
providers.push({ provide: dependency.className, variable });
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
const entry = doubleOf(table, dependency.token);
|
|
163
|
+
const emit = EMITTERS[entry.double];
|
|
164
|
+
if (!emit) throw new ScaffoldError('HFS_NEW_DOUBLE_UNKNOWN', `the slot manifest names the double ${entry.double} for ${dependency.token}, which hfs new cannot write yet`);
|
|
165
|
+
const made = emit(dependency);
|
|
166
|
+
for (const name of made.uses ?? []) kit.add(name);
|
|
167
|
+
if (made.clock) { needsClock = true; kit.add('FakeClock'); }
|
|
168
|
+
addImport(imports, dependency.module, dependency.token);
|
|
169
|
+
for (const type of dependency.type.imports) if (made.uses?.includes('mock')) addImport(typeImports, type.module, type.name);
|
|
170
|
+
if (entry.double === 'FakeClock') { providers.push({ provide: dependency.token, variable: 'clock' }); continue; }
|
|
171
|
+
const variable = uniqueName(camelOf(dependency.name), taken);
|
|
172
|
+
variables.push({ variable, code: made.code, note: made.note });
|
|
173
|
+
if (made.note) notes.push(made.note);
|
|
174
|
+
providers.push({ provide: dependency.token, variable });
|
|
175
|
+
}
|
|
176
|
+
if (shape.dependencies.some(dependency => dependency.kind === 'token' && doubleOf(table, dependency.token).double === 'FakeClock')) needsClock = true;
|
|
177
|
+
|
|
178
|
+
const lines = [];
|
|
179
|
+
lines.push(importLine(['Test'], '@nestjs/testing'));
|
|
180
|
+
if (kit.size) lines.push(importLine([...kit].sort((a, b) => a.localeCompare(b, 'en', { sensitivity: 'base' })), KIT));
|
|
181
|
+
const modules = [...new Set([...imports.keys(), ...typeImports.keys()])].sort(compareModules);
|
|
182
|
+
for (const module of modules) {
|
|
183
|
+
if (imports.has(module)) lines.push(importLine([...imports.get(module)].sort(), module));
|
|
184
|
+
if (typeImports.has(module)) lines.push(importLine([...typeImports.get(module)].sort(), module, true));
|
|
185
|
+
}
|
|
186
|
+
lines.push(`import { ${shape.className} } from "${subject}"`);
|
|
187
|
+
lines.push('');
|
|
188
|
+
|
|
189
|
+
const build = [];
|
|
190
|
+
build.push('const build = async () => {');
|
|
191
|
+
if (needsClock) build.push(`${INDENT}const clock = new FakeClock("${CLOCK_START}")`);
|
|
192
|
+
for (const item of variables) {
|
|
193
|
+
if (item.note) build.push(`${INDENT}// Fill in ${item.note}.`);
|
|
194
|
+
build.push(`${INDENT}const ${item.variable} = ${item.code}`);
|
|
195
|
+
}
|
|
196
|
+
build.push(`${INDENT}const moduleRef = await Test.createTestingModule({`);
|
|
197
|
+
build.push(`${INDENT}${INDENT}providers: [`);
|
|
198
|
+
for (const provider of providers) build.push(`${INDENT}${INDENT}${INDENT}${typeof provider === 'string' ? provider : `{ provide: ${provider.provide}, useValue: ${provider.variable} }`},`);
|
|
199
|
+
build.push(`${INDENT}${INDENT}],`);
|
|
200
|
+
build.push(`${INDENT}}).compile()`);
|
|
201
|
+
const returned = ['service: moduleRef.get(' + shape.className + ')', ...(needsClock ? ['clock'] : []), ...variables.map(item => item.variable)];
|
|
202
|
+
const single = `${INDENT}return { ${returned.join(', ')} }`;
|
|
203
|
+
if (single.length <= PRINT_WIDTH) build.push(single);
|
|
204
|
+
else build.push(`${INDENT}return {`, ...returned.map(item => `${INDENT}${INDENT}${item},`), `${INDENT}}`);
|
|
205
|
+
build.push('}');
|
|
206
|
+
lines.push(...build, '');
|
|
207
|
+
|
|
208
|
+
lines.push(`describe("${shape.className}", () => {`);
|
|
209
|
+
if (shape.methods.length === 0) {
|
|
210
|
+
lines.push(`${INDENT}it("is built by the testing module with its constructor dependencies", async () => {`);
|
|
211
|
+
lines.push(`${INDENT}${INDENT}const { service } = await build()`);
|
|
212
|
+
lines.push(`${INDENT}${INDENT}expect(service).toBeInstanceOf(${shape.className})`);
|
|
213
|
+
lines.push(`${INDENT}})`);
|
|
214
|
+
} else {
|
|
215
|
+
shape.methods.forEach((method, index) => {
|
|
216
|
+
if (index > 0) lines.push('');
|
|
217
|
+
lines.push(`${INDENT}describe("${method}", () => {`);
|
|
218
|
+
lines.push(`${INDENT}${INDENT}it("${method} has its first case written", async () => {`);
|
|
219
|
+
lines.push(`${INDENT}${INDENT}${INDENT}const { service } = await build()`);
|
|
220
|
+
lines.push(`${INDENT}${INDENT}${INDENT}expect(service.${method}).toBeInstanceOf(Function)`);
|
|
221
|
+
lines.push(`${INDENT}${INDENT}})`);
|
|
222
|
+
lines.push(`${INDENT}})`);
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
lines.push('})');
|
|
226
|
+
return `${lines.join('\n')}\n`;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function uniqueName(name, taken) {
|
|
230
|
+
let candidate = name;
|
|
231
|
+
for (let counter = 2; taken.has(candidate); counter += 1) candidate = `${name}${counter}`;
|
|
232
|
+
taken.add(candidate);
|
|
233
|
+
return candidate;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// ------------------------------------------------------------------------------------------------ writing the service
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* `--inject` entries: `InjectCache=@modules/integrations/cache:Cache` (a token dependency: decorator, the module that exports the
|
|
240
|
+
* decorator and its token, the parameter type) or `ProbeCheckerService=@modules/platform/probes` (a class dependency).
|
|
241
|
+
*/
|
|
242
|
+
export function parseInject(entry) {
|
|
243
|
+
const match = /^([A-Za-z][A-Za-z0-9]*)=([^:\s]+)(?::([A-Za-z][A-Za-z0-9<>, ]*))?$/.exec(entry);
|
|
244
|
+
if (!match) throw new ScaffoldError('HFS_NEW_INJECT_INVALID', `--inject ${entry}: expected InjectName=<module>:<Type> or ClassName=<module>`);
|
|
245
|
+
const [, name, module, type] = match;
|
|
246
|
+
if (name.startsWith('Inject')) {
|
|
247
|
+
if (!type) throw new ScaffoldError('HFS_NEW_INJECT_INVALID', `--inject ${entry}: a token dependency names its type after a colon (InjectCache=<module>:Cache)`);
|
|
248
|
+
return { kind: 'token', decorator: name, module, type, parameter: camelOf(type.replace(/<.*$/, '')) };
|
|
249
|
+
}
|
|
250
|
+
if (type) throw new ScaffoldError('HFS_NEW_INJECT_INVALID', `--inject ${entry}: a class dependency takes no type`);
|
|
251
|
+
return { kind: 'class', className: name, module, parameter: camelOf(name) };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** The text of a new `<name>.service.ts` with the given dependencies (parseInject results). */
|
|
255
|
+
export function serviceSource({ name, dependencies = [] }) {
|
|
256
|
+
const className = `${pascalOf(name)}Service`;
|
|
257
|
+
const values = new Map();
|
|
258
|
+
const types = new Map();
|
|
259
|
+
const add = (map, module, item) => { if (!map.has(module)) map.set(module, new Set()); map.get(module).add(item); };
|
|
260
|
+
add(values, '@nestjs/common', 'Injectable');
|
|
261
|
+
for (const dependency of dependencies) {
|
|
262
|
+
if (dependency.kind === 'token') { add(values, dependency.module, dependency.decorator); add(types, dependency.module, dependency.type.replace(/<.*$/, '')); } else add(values, dependency.module, dependency.className);
|
|
263
|
+
}
|
|
264
|
+
const lines = [];
|
|
265
|
+
for (const module of [...new Set([...values.keys(), ...types.keys()])].sort(compareModules)) {
|
|
266
|
+
if (values.has(module)) lines.push(importLine([...values.get(module)].sort(), module));
|
|
267
|
+
if (types.has(module)) lines.push(importLine([...types.get(module)].sort(), module, true));
|
|
268
|
+
}
|
|
269
|
+
lines.push('', '@Injectable()', `/** The ${words(name).join(' ')} service: state what it decides in one sentence. */`);
|
|
270
|
+
if (dependencies.length === 0) lines.push(`export class ${className} {}`);
|
|
271
|
+
else {
|
|
272
|
+
lines.push(`export class ${className} {`, `${INDENT}constructor(`);
|
|
273
|
+
for (const dependency of dependencies) lines.push(dependency.kind === 'token' ? `${INDENT}${INDENT}@${dependency.decorator}() private readonly ${dependency.parameter}: ${dependency.type},` : `${INDENT}${INDENT}private readonly ${dependency.parameter}: ${dependency.className},`);
|
|
274
|
+
lines.push(`${INDENT}) {}`, '}');
|
|
275
|
+
}
|
|
276
|
+
return `${lines.join('\n')}\n`;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// ------------------------------------------------------------------------------------------------ the commands
|
|
280
|
+
|
|
281
|
+
const loadTypeScript = repoRoot => {
|
|
282
|
+
try { return createRequire(path.join(repoRoot, 'package.json'))('typescript'); } catch {
|
|
283
|
+
throw new ScaffoldError('HFS_NEW_TYPESCRIPT_MISSING', `typescript is not installed under ${repoRoot}; run npm ci first, hfs new reads the constructor with the repository's own TypeScript`);
|
|
284
|
+
}
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
const profileOf = repoRoot => {
|
|
288
|
+
let declaration;
|
|
289
|
+
try { declaration = JSON.parse(fs.readFileSync(path.join(repoRoot, 'hfs.json'), 'utf8')); } catch { throw new ScaffoldError('HFS_NEW_NO_HFS', `${repoRoot} has no readable hfs.json`); }
|
|
290
|
+
if (declaration.profile !== 'be') throw new ScaffoldError('HFS_NEW_BACKEND_ONLY', 'hfs new service | spec writes back-end services; this repository is a front end');
|
|
291
|
+
return declaration;
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
/** The slot check of a target: the manifest must own the path, else nothing is written. */
|
|
295
|
+
const requireSlot = (repoRoot, relative) => {
|
|
296
|
+
const explained = explainPath({ repoRoot, input: relative });
|
|
297
|
+
if (explained.status === 'no-slot' || explained.status === 'ambiguous') throw new ScaffoldError('HFS_NEW_NO_SLOT', `${relative} is owned by no slot of the HFS manifest (${explained.status}); a service belongs in src/modules/{domain,platform,integrations}/<capability>/`);
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
const writeNew = (repoRoot, relative, text) => {
|
|
301
|
+
const target = path.join(repoRoot, relative);
|
|
302
|
+
if (fs.existsSync(target)) throw new ScaffoldError('HFS_NEW_EXISTS', `${relative} already exists; hfs new never overwrites`);
|
|
303
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
304
|
+
fs.writeFileSync(target, text);
|
|
305
|
+
return relative;
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
/** `hfs new spec <file>.service.ts`: writes the spec skeleton of an existing service; returns the created paths. */
|
|
309
|
+
export function newSpec({ repoRoot, file, ts = loadTypeScript(repoRoot), manifest = loadSlotManifest() }) {
|
|
310
|
+
profileOf(repoRoot);
|
|
311
|
+
const relative = file.split(path.sep).join('/').replace(/^\.\//, '');
|
|
312
|
+
if (!relative.endsWith('.service.ts')) throw new ScaffoldError('HFS_NEW_NOT_A_SERVICE', `${relative} is not a *.service.ts file: only services have a unit spec`);
|
|
313
|
+
const absolute = path.join(repoRoot, relative);
|
|
314
|
+
if (!fs.existsSync(absolute)) throw new ScaffoldError('HFS_NEW_NO_SERVICE', `${relative} does not exist`);
|
|
315
|
+
const specPath = relative.replace(/\.ts$/, '.spec.ts');
|
|
316
|
+
requireSlot(repoRoot, specPath);
|
|
317
|
+
const shape = readServiceShape({ ts, fileName: absolute, text: fs.readFileSync(absolute, 'utf8') });
|
|
318
|
+
return [writeNew(repoRoot, specPath, specSkeleton({ shape, serviceFile: relative, manifest }))];
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** `hfs new service <dir> <name> [--inject ...]`: writes the service and its spec skeleton; returns the created paths. */
|
|
322
|
+
export function newService({ repoRoot, dir, name, inject = [], ts = loadTypeScript(repoRoot), manifest = loadSlotManifest() }) {
|
|
323
|
+
profileOf(repoRoot);
|
|
324
|
+
if (!SERVICE_NAME.test(name)) throw new ScaffoldError('HFS_NEW_NAME_INVALID', `the service name ${name} must be kebab-case (member-profile), without the .service suffix`);
|
|
325
|
+
const relative = path.posix.join(dir.split(path.sep).join('/').replace(/^\.\//, '').replace(/\/$/, ''), `${name}.service.ts`);
|
|
326
|
+
requireSlot(repoRoot, relative);
|
|
327
|
+
requireSlot(repoRoot, relative.replace(/\.ts$/, '.spec.ts'));
|
|
328
|
+
for (const target of [relative, relative.replace(/\.ts$/, '.spec.ts')]) if (fs.existsSync(path.join(repoRoot, target))) throw new ScaffoldError('HFS_NEW_EXISTS', `${target} already exists; hfs new never overwrites`);
|
|
329
|
+
const text = serviceSource({ name, dependencies: inject.map(parseInject) });
|
|
330
|
+
const shape = readServiceShape({ ts, fileName: path.join(repoRoot, relative), text });
|
|
331
|
+
const spec = specSkeleton({ shape, serviceFile: relative, manifest });
|
|
332
|
+
return [writeNew(repoRoot, relative, text), writeNew(repoRoot, relative.replace(/\.ts$/, '.spec.ts'), spec)];
|
|
333
|
+
}
|
package/sync/format.mjs
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// format.mjs - HFS_FORMAT (R19): Prettier is the only formatter, and every tracked file it formats is formatted.
|
|
2
|
+
// The check is `prettier --check` over the tracked files through the repository's OWN prettier (resolved from the repository,
|
|
3
|
+
// with the repository's prettier config and .prettierignore), so it judges what the repository's pre-commit and CI judge. A
|
|
4
|
+
// repository with no installed prettier is a refusal (HFS_FORMAT_TOOL_MISSING), never a pass. It is slow and is not part of `--fast`.
|
|
5
|
+
import { createRequire } from 'node:module';
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { SyncError } from './index.mjs';
|
|
9
|
+
|
|
10
|
+
export const FORMAT = 'HFS_FORMAT';
|
|
11
|
+
export const IGNORE_FILE = '.prettierignore';
|
|
12
|
+
/** Lockfiles are written by npm, not by people: they are never formatted. */
|
|
13
|
+
const LOCKFILES = new Set(['package-lock.json']);
|
|
14
|
+
|
|
15
|
+
/** The repository's own prettier, or a SyncError when it is not installed. */
|
|
16
|
+
export function loadPrettier(repoRoot) {
|
|
17
|
+
const require = createRequire(path.join(repoRoot, 'package.json'));
|
|
18
|
+
try {
|
|
19
|
+
return require('prettier');
|
|
20
|
+
} catch {
|
|
21
|
+
throw new SyncError('HFS_FORMAT_TOOL_MISSING', `prettier is not installed under ${repoRoot}; install the version knowledge/hfs/canon-pins.yaml pins, so the format check judges what the repository's own gates judge`);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The HFS_FORMAT findings over the tracked `files` of `repoRoot`: one per file prettier would change, or cannot parse.
|
|
27
|
+
* `prettier` is a seam for specs; the default is the repository's own.
|
|
28
|
+
*/
|
|
29
|
+
export async function formatFindings({ repoRoot, files, prettier = loadPrettier(repoRoot) }) {
|
|
30
|
+
const ignorePath = path.join(repoRoot, IGNORE_FILE);
|
|
31
|
+
const findings = [];
|
|
32
|
+
for (const file of files.filter((f) => !LOCKFILES.has(path.posix.basename(f)))) {
|
|
33
|
+
const absolute = path.join(repoRoot, file);
|
|
34
|
+
let text;
|
|
35
|
+
try { text = fs.readFileSync(absolute, 'utf8'); } catch { continue; }
|
|
36
|
+
const info = await prettier.getFileInfo(absolute, { ignorePath: fs.existsSync(ignorePath) ? ignorePath : undefined, resolveConfig: true });
|
|
37
|
+
if (info.ignored || !info.inferredParser) continue;
|
|
38
|
+
const options = (await prettier.resolveConfig(absolute, { editorconfig: true, useCache: false })) ?? {};
|
|
39
|
+
try {
|
|
40
|
+
if (!(await prettier.check(text, { ...options, filepath: absolute }))) findings.push({ code: FORMAT, level: 'error', path: file, message: `${file} is not formatted by prettier; run \`npm run format\`` });
|
|
41
|
+
} catch (error) {
|
|
42
|
+
findings.push({ code: FORMAT, level: 'error', path: file, message: `${file} cannot be formatted: ${String(error?.message ?? error).split('\n')[0]}` });
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return findings;
|
|
46
|
+
}
|
package/sync/hygiene.mjs
CHANGED
|
@@ -1,16 +1,21 @@
|
|
|
1
1
|
// hfs work-hygiene: the guard for the two trees a back end tracks besides source. A file under .starciwork must be
|
|
2
2
|
// product content (the .starciwork/.gitignore allowlist admits it, so agent output is refused), and a file under
|
|
3
|
-
// .starcistacks must not be a plaintext secret (only *.enc is sealed).
|
|
4
|
-
// scripts/
|
|
3
|
+
// .starcistacks must not be a plaintext secret (only *.enc is sealed). It is also the secrets guard of the commit: every staged file, in
|
|
4
|
+
// any tree, is read from the index and judged with the one secret judgement of `hfs check` (scripts/lib/hfs-rules/secrets.mjs: a secret by
|
|
5
|
+
// being, an .enc that is no sops envelope, a line that matches a secret pattern), so no plaintext secret reaches the history whatever
|
|
6
|
+
// .gitignore says (`git add -f`, a path tracked before a rule tightened). There is no override. The pre-commit hook judges the staged
|
|
7
|
+
// files; scripts/checks/check-hfs-sync.mjs judges every tracked file.
|
|
5
8
|
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
9
|
+
// Run inside a full runtime checkout — the repository under judgment is the checkout — it also reports the
|
|
10
|
+
// state-root ledger findings of that checkout's scripts/lib/hk-orphan-ledgers.mjs: LEDGER_ORPHAN_STATE_ROOT and
|
|
11
|
+
// LEDGER_LEGACY_WORK_SQLITE (COOK-BRIEF F4 handover, incident 2026-09-30). Any other repository — a product repo,
|
|
12
|
+
// a bare repo — carries no scripts/checks/ledger-hygiene.mjs at its root, so that section is silently absent:
|
|
13
|
+
// never a crash, never machine-state findings blamed on a repository that does not own them.
|
|
10
14
|
import { execFileSync } from 'node:child_process';
|
|
11
15
|
import fs from 'node:fs';
|
|
12
16
|
import path from 'node:path';
|
|
13
|
-
import {
|
|
17
|
+
import { pathToFileURL } from 'node:url';
|
|
18
|
+
import { secretFileFindings } from '../runtime/scripts/lib/hfs-rules/secrets.mjs';
|
|
14
19
|
|
|
15
20
|
const PLAINTEXT_NAME = /(^|\/)(\.env(\..*)?|[^/]*\.(pem|key|identity|age))$/;
|
|
16
21
|
const GUARDED = file => file.startsWith('.starciwork/') || file.startsWith('.starcistacks/');
|
|
@@ -35,8 +40,8 @@ export function hygieneFindings(files, ignored) {
|
|
|
35
40
|
findings.push({ file, code: 'HFS_WORK_AGENT_DATA', message: 'is agent output, which .starciwork/.gitignore refuses; keep it in the scratchpad or the blob store' });
|
|
36
41
|
}
|
|
37
42
|
if (file.startsWith('.starcistacks/') && !file.endsWith('.enc') && !file.endsWith('.env.example')) {
|
|
38
|
-
if (file.includes('/secrets/')) findings.push({ file, code: '
|
|
39
|
-
else if (PLAINTEXT_NAME.test(file)) findings.push({ file, code: '
|
|
43
|
+
if (file.includes('/secrets/')) findings.push({ file, code: 'HFS_PLAINTEXT_SECRET', message: 'sits under secrets/ but is not sealed; only <slug>.enc may be tracked' });
|
|
44
|
+
else if (PLAINTEXT_NAME.test(file)) findings.push({ file, code: 'HFS_PLAINTEXT_SECRET', message: 'is a plaintext secret; seal it to .starcistacks/<env>/secrets/<slug>.enc' });
|
|
40
45
|
}
|
|
41
46
|
}
|
|
42
47
|
return findings;
|
|
@@ -50,41 +55,68 @@ export const stagedFiles = cwd => gitList(cwd, ['diff', '--cached', '--name-only
|
|
|
50
55
|
/** Every tracked file. */
|
|
51
56
|
export const trackedFiles = cwd => gitList(cwd, ['ls-files', '-z']);
|
|
52
57
|
|
|
53
|
-
|
|
58
|
+
const MAX_STAGED_BYTES = 1024 * 1024;
|
|
59
|
+
|
|
60
|
+
/** The text of `file` as the index holds it (what the commit would record), or null when it is absent, binary or over 1 MB. */
|
|
61
|
+
export function stagedText(cwd, file) {
|
|
62
|
+
try {
|
|
63
|
+
const blob = execFileSync('git', ['show', `:${file}`], { cwd, maxBuffer: MAX_STAGED_BYTES * 2, stdio: ['ignore', 'pipe', 'ignore'] });
|
|
64
|
+
return blob.length > MAX_STAGED_BYTES || blob.includes(0) ? null : blob.toString('utf8');
|
|
65
|
+
} catch {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The secret findings of `files` read from the index, as {file, code, message}; a file the name check already refused is not reported twice. */
|
|
71
|
+
export function secretGuardFindings(cwd, files, alreadyRefused = new Set()) {
|
|
72
|
+
return files.filter(file => !alreadyRefused.has(file)).flatMap(file => secretFileFindings({ file, text: stagedText(cwd, file) }).map(finding => ({ file, code: finding.code, message: finding.message })));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Findings for `files`: the guarded trees, then the secret guard over every file. */
|
|
54
76
|
export function judge(cwd, files) {
|
|
55
77
|
const guarded = files.filter(GUARDED);
|
|
56
|
-
|
|
78
|
+
const findings = hygieneFindings(guarded, ignoredAmong(cwd, guarded.filter(file => file.startsWith('.starciwork/'))));
|
|
79
|
+
const refused = new Set(findings.filter(finding => finding.code === 'HFS_PLAINTEXT_SECRET').map(finding => finding.file));
|
|
80
|
+
return { checked: files.length, findings: [...findings, ...secretGuardFindings(cwd, files, refused)] };
|
|
57
81
|
}
|
|
58
82
|
|
|
59
|
-
// The
|
|
60
|
-
// runtime checkout (packages/hfs/sync/
|
|
61
|
-
// packages/hfs/scripts/sync-runtime.mjs uses).
|
|
62
|
-
//
|
|
63
|
-
const
|
|
83
|
+
// The scripts/checks/ledger-hygiene.mjs of the checkout under judgment: the root git names for `cwd`, which carries
|
|
84
|
+
// that script only when the repository IS a full StarCi runtime checkout (packages/hfs/sync/ sits 3 directories
|
|
85
|
+
// under such a root, the same computation packages/hfs/scripts/sync-runtime.mjs uses). A product repository — or a
|
|
86
|
+
// bare repo — has no such file, so the section is silently absent wherever this module happens to be installed.
|
|
87
|
+
const ledgerHygieneScript = cwd => {
|
|
88
|
+
try {
|
|
89
|
+
const root = execFileSync('git', ['rev-parse', '--show-toplevel'], { cwd, encoding: 'utf8' }).trim();
|
|
90
|
+
return path.join(root, 'scripts', 'checks', 'ledger-hygiene.mjs');
|
|
91
|
+
} catch {
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
};
|
|
64
95
|
|
|
65
96
|
/**
|
|
66
|
-
* The state-root ledger findings (LEDGER_ORPHAN_STATE_ROOT, LEDGER_LEGACY_WORK_SQLITE)
|
|
67
|
-
*
|
|
68
|
-
* one HFS_LEDGER_HYGIENE_UNAVAILABLE finding, not a crash of `hfs work-hygiene`.
|
|
97
|
+
* The state-root ledger findings (LEDGER_ORPHAN_STATE_ROOT, LEDGER_LEGACY_WORK_SQLITE) of the checkout containing
|
|
98
|
+
* `cwd`, as {code, file, message} entries; [] when that checkout carries no ledger-hygiene script. Never throws: a
|
|
99
|
+
* report failure is one HFS_LEDGER_HYGIENE_UNAVAILABLE finding, not a crash of `hfs work-hygiene`.
|
|
69
100
|
*/
|
|
70
|
-
export async function ledgerHygieneFindings() {
|
|
71
|
-
|
|
101
|
+
export async function ledgerHygieneFindings(cwd = process.cwd()) {
|
|
102
|
+
const script = ledgerHygieneScript(cwd);
|
|
103
|
+
if (!script || !fs.existsSync(script)) return [];
|
|
72
104
|
try {
|
|
73
|
-
const { ledgerHygieneReport } = await import(pathToFileURL(
|
|
105
|
+
const { ledgerHygieneReport } = await import(pathToFileURL(script).href);
|
|
74
106
|
const report = await ledgerHygieneReport({ apply: false });
|
|
75
107
|
return [
|
|
76
108
|
...report.orphans.map(o => ({ code: o.code, file: o.ledgerId, message: `ledger ${o.name ?? o.ledgerId} - ${o.reason}; source roots: ${o.sourceRoots.join(', ') || '(none)'}` })),
|
|
77
109
|
...report.legacy.map(l => ({ code: l.code, file: l.repoRoot, message: `${l.files.length} legacy file(s) still in the repo: ${l.files.join(', ')}` })),
|
|
78
110
|
];
|
|
79
111
|
} catch (error) {
|
|
80
|
-
return [{ code: 'HFS_LEDGER_HYGIENE_UNAVAILABLE', file:
|
|
112
|
+
return [{ code: 'HFS_LEDGER_HYGIENE_UNAVAILABLE', file: script, message: `could not run: ${String(error?.message ?? error).slice(0, 200)}` }];
|
|
81
113
|
}
|
|
82
114
|
}
|
|
83
115
|
|
|
84
116
|
/** `hfs work-hygiene`: checks the staged files, plus the state-root ledger findings when reachable; returns the exit code. */
|
|
85
117
|
export async function runWorkHygiene({ cwd = process.cwd(), out = line => process.stdout.write(`${line}\n`), files } = {}) {
|
|
86
118
|
const { checked, findings } = judge(cwd, files ?? stagedFiles(cwd));
|
|
87
|
-
const ledgerFindings = await ledgerHygieneFindings();
|
|
119
|
+
const ledgerFindings = await ledgerHygieneFindings(cwd);
|
|
88
120
|
const all = [...findings, ...ledgerFindings];
|
|
89
121
|
for (const finding of all) out(`${finding.code} ${finding.file} ${finding.message}`);
|
|
90
122
|
out(`hfs work-hygiene: ${checked} staged file(s) checked, ${findings.length} finding(s), ${ledgerFindings.length} ledger finding(s)`);
|