@starci/hfs 1.0.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +57 -0
- package/README.md +116 -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 +10 -2
- 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 +409 -140
- 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 +95 -41
- 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 +126 -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 +21 -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,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JSON Schema (2020-12, the dialect of OpenAPI 3.1) of a TypeScript type, read from the type checker.
|
|
3
|
+
*
|
|
4
|
+
* Deterministic: properties, enum members and union members are sorted; object types with a name become components (`$ref`) once,
|
|
5
|
+
* in the order they are met. Undecidable types are errors naming the path inside the type, never `{}`: `any`, `unknown`, `never`,
|
|
6
|
+
* `object`, an unbound generic, a function, a class of the standard library (Date, Map, Set, Promise), a bigint, a symbol, an
|
|
7
|
+
* object type with no members and no index signature, and a union with `undefined` where a member could not simply be absent.
|
|
8
|
+
* Pure: takes the TypeScript module and a checker.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** Sorts object keys at every depth, so equal schemas print equal text. */
|
|
12
|
+
export function stable(value) {
|
|
13
|
+
if (Array.isArray(value)) return value.map(stable);
|
|
14
|
+
if (value && typeof value === 'object') return Object.fromEntries(Object.keys(value).sort().map((key) => [key, stable(value[key])]));
|
|
15
|
+
return value;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const canonical = (value) => JSON.stringify(stable(value));
|
|
19
|
+
|
|
20
|
+
/** A builder over one checker; `components` fills as named object types are met. */
|
|
21
|
+
export function createSchemaBuilder({ ts, checker }) {
|
|
22
|
+
const F = ts.TypeFlags;
|
|
23
|
+
const components = new Map();
|
|
24
|
+
const nameOf = new Map();
|
|
25
|
+
const taken = new Set();
|
|
26
|
+
const fail = (path, why) => {
|
|
27
|
+
throw new Error(`${path}: ${why}`);
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const isLibrary = (symbol) => (symbol?.getDeclarations?.() ?? []).some((declaration) => /[\\/]typescript[\\/]lib[\\/]lib\./.test(declaration.getSourceFile().fileName));
|
|
31
|
+
|
|
32
|
+
const componentName = (type) => {
|
|
33
|
+
const symbol = type.aliasSymbol ?? type.getSymbol();
|
|
34
|
+
const written = checker.typeToString(type, undefined, ts.TypeFormatFlags.NoTruncation | ts.TypeFormatFlags.UseAliasDefinedOutsideCurrentScope);
|
|
35
|
+
const base = (symbol && !/^__/.test(symbol.getName()) && !isLibrary(symbol) ? written : null);
|
|
36
|
+
if (!base) return null;
|
|
37
|
+
const clean = base.replace(/[^A-Za-z0-9._-]+/g, '.').replace(/^\.+|\.+$/g, '');
|
|
38
|
+
let name = clean;
|
|
39
|
+
for (let n = 2; taken.has(name); n += 1) name = `${clean}_${n}`;
|
|
40
|
+
taken.add(name);
|
|
41
|
+
return name;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const withoutUndefined = (type) => (type.isUnion() ? type.types.filter((member) => !(member.flags & (F.Undefined | F.Void))) : [type]);
|
|
45
|
+
|
|
46
|
+
/** The schema of a set of union members (no `undefined` among them). */
|
|
47
|
+
function membersSchema(members, path) {
|
|
48
|
+
const hasTrue = members.some((member) => member.flags & F.BooleanLiteral && checker.typeToString(member) === 'true');
|
|
49
|
+
const hasFalse = members.some((member) => member.flags & F.BooleanLiteral && checker.typeToString(member) === 'false');
|
|
50
|
+
const parts = [];
|
|
51
|
+
let rest = members;
|
|
52
|
+
if (hasTrue && hasFalse) {
|
|
53
|
+
rest = members.filter((member) => !(member.flags & F.BooleanLiteral));
|
|
54
|
+
parts.push({ type: 'boolean' });
|
|
55
|
+
}
|
|
56
|
+
const consts = [];
|
|
57
|
+
for (const member of rest) {
|
|
58
|
+
if (member.flags & (F.StringLiteral | F.NumberLiteral)) consts.push(member.value);
|
|
59
|
+
else if (member.flags & F.BooleanLiteral) consts.push(checker.typeToString(member) === 'true');
|
|
60
|
+
else parts.push(schemaOf(member, path));
|
|
61
|
+
}
|
|
62
|
+
if (consts.length) {
|
|
63
|
+
const sorted = [...consts].sort((a, b) => (String(a) < String(b) ? -1 : String(a) > String(b) ? 1 : 0));
|
|
64
|
+
const kinds = new Set(sorted.map((item) => typeof item));
|
|
65
|
+
parts.push(kinds.size === 1 && !kinds.has('boolean') ? { enum: sorted, type: kinds.has('string') ? 'string' : 'number' } : { enum: sorted });
|
|
66
|
+
}
|
|
67
|
+
const unique = [...new Map(parts.map((part) => [canonical(part), part])).values()].sort((a, b) => (canonical(a) < canonical(b) ? -1 : 1));
|
|
68
|
+
return unique.length === 1 ? unique[0] : { anyOf: unique };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function unionSchema(type, path) {
|
|
72
|
+
if (type.types.some((member) => member.flags & (F.Undefined | F.Void))) fail(path, 'undefined is not a wire value here (declare the member optional instead)');
|
|
73
|
+
return membersSchema(type.types, path);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function objectSchema(type, path) {
|
|
77
|
+
if (checker.isArrayType(type) || checker.isTupleType(type)) {
|
|
78
|
+
if (checker.isTupleType(type)) {
|
|
79
|
+
const items = checker.getTypeArguments(type).map((item, index) => schemaOf(item, `${path}[${index}]`));
|
|
80
|
+
return { type: 'array', prefixItems: items, minItems: items.length, maxItems: items.length };
|
|
81
|
+
}
|
|
82
|
+
return { type: 'array', items: schemaOf(checker.getTypeArguments(type)[0], `${path}[]`) };
|
|
83
|
+
}
|
|
84
|
+
if (type.getCallSignatures().length || type.getConstructSignatures().length) fail(path, 'a function is not a wire value');
|
|
85
|
+
const own = type.getSymbol();
|
|
86
|
+
if (own && !own.getName().startsWith('__') && isLibrary(own) && !/^(Readonly)?Array$/.test(own.getName())) fail(path, `${own.getName()} is not a JSON value (send a string or a plain object type)`);
|
|
87
|
+
const existing = nameOf.get(type);
|
|
88
|
+
if (existing) return { $ref: `#/components/schemas/${existing}` };
|
|
89
|
+
const name = componentName(type);
|
|
90
|
+
if (name) {
|
|
91
|
+
nameOf.set(type, name);
|
|
92
|
+
components.set(name, null);
|
|
93
|
+
}
|
|
94
|
+
const built = plainObject(type, path);
|
|
95
|
+
if (!name) return built;
|
|
96
|
+
components.set(name, built);
|
|
97
|
+
return { $ref: `#/components/schemas/${name}` };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function plainObject(type, path) {
|
|
101
|
+
const properties = {};
|
|
102
|
+
const required = [];
|
|
103
|
+
const props = [...checker.getPropertiesOfType(type)].sort((a, b) => (a.getName() < b.getName() ? -1 : 1));
|
|
104
|
+
for (const prop of props) {
|
|
105
|
+
const propType = checker.getTypeOfSymbol(prop);
|
|
106
|
+
const optional = (prop.flags & ts.SymbolFlags.Optional) !== 0;
|
|
107
|
+
const members = propType.isUnion() ? propType.types : [propType];
|
|
108
|
+
const kept = withoutUndefined(propType);
|
|
109
|
+
if (kept.length === 0) fail(`${path}.${prop.getName()}`, 'undefined is not a wire value');
|
|
110
|
+
const hadUndefined = kept.length < members.length;
|
|
111
|
+
properties[prop.getName()] = !hadUndefined ? schemaOf(propType, `${path}.${prop.getName()}`) : kept.length === 1 ? schemaOf(kept[0], `${path}.${prop.getName()}`) : membersSchema(kept, `${path}.${prop.getName()}`);
|
|
112
|
+
if (!optional && !hadUndefined) required.push(prop.getName());
|
|
113
|
+
}
|
|
114
|
+
const index = checker.getIndexInfosOfType(type).find((info) => info.keyType.flags & F.String);
|
|
115
|
+
if (props.length === 0 && !index) fail(path, 'an object type with no members and no index signature says nothing');
|
|
116
|
+
const result = { type: 'object' };
|
|
117
|
+
if (props.length) result.properties = properties;
|
|
118
|
+
if (required.length) result.required = required;
|
|
119
|
+
if (index) result.additionalProperties = schemaOf(index.type, `${path}[key]`);
|
|
120
|
+
return result;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function schemaOf(type, path) {
|
|
124
|
+
const f = type.flags;
|
|
125
|
+
if (f & F.Any) fail(path, 'any');
|
|
126
|
+
if (f & F.Unknown) fail(path, 'unknown');
|
|
127
|
+
if (f & F.Never) fail(path, 'never');
|
|
128
|
+
if (f & (F.Undefined | F.Void)) fail(path, 'undefined is not a wire value');
|
|
129
|
+
if (f & F.TypeParameter) fail(path, 'an unbound generic');
|
|
130
|
+
if (f & F.NonPrimitive) fail(path, 'object with no declared members');
|
|
131
|
+
if (f & (F.BigInt | F.BigIntLiteral | F.ESSymbol | F.UniqueESSymbol)) fail(path, 'bigint and symbol are not JSON values');
|
|
132
|
+
if (f & F.StringLiteral) return { const: type.value };
|
|
133
|
+
if (f & F.NumberLiteral) return { const: type.value };
|
|
134
|
+
if (f & F.BooleanLiteral) return { const: checker.typeToString(type) === 'true' };
|
|
135
|
+
if (f & F.String) return { type: 'string' };
|
|
136
|
+
if (f & F.Number) return { type: 'number' };
|
|
137
|
+
if (f & F.Boolean) return { type: 'boolean' };
|
|
138
|
+
if (f & F.Null) return { type: 'null' };
|
|
139
|
+
if (type.isUnion()) return unionSchema(type, path);
|
|
140
|
+
if (f & F.Object || f & F.Intersection) return objectSchema(type, path);
|
|
141
|
+
return fail(path, `a type this reader cannot express (${checker.typeToString(type)})`);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return { schemaOf, components: () => Object.fromEntries([...components.entries()].sort(([a], [b]) => (a < b ? -1 : 1))) };
|
|
145
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@starci/hfs",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.0",
|
|
4
4
|
"description": "The HFS command line of a StarCi product repository: hfs check, init, explain, sync and work-hygiene. Self-contained: it carries the runtime files it reads.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "UNLICENSED",
|
|
@@ -8,12 +8,20 @@
|
|
|
8
8
|
"bin": {
|
|
9
9
|
"hfs": "./bin/hfs.mjs"
|
|
10
10
|
},
|
|
11
|
+
"exports": {
|
|
12
|
+
"./package.json": "./package.json",
|
|
13
|
+
"./runtime/*": "./runtime/*"
|
|
14
|
+
},
|
|
11
15
|
"files": [
|
|
12
16
|
"bin/hfs.mjs",
|
|
17
|
+
"emit/**",
|
|
18
|
+
"scaffold/**",
|
|
19
|
+
"report/**",
|
|
13
20
|
"runtime/**",
|
|
14
21
|
"sync/**",
|
|
15
22
|
"templates/**",
|
|
16
|
-
"README.md"
|
|
23
|
+
"README.md",
|
|
24
|
+
"CHANGELOG.md"
|
|
17
25
|
],
|
|
18
26
|
"engines": {
|
|
19
27
|
"node": ">=22.13.0"
|
package/report/sonar.mjs
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
// hfs report: the ONE way the findings of the StarCi canon reach Sonar (contract change hfs-sonar-import).
|
|
2
|
+
//
|
|
3
|
+
// hfs check --sonar <file> writes every error-level finding of the check (repository, managed files and the whole
|
|
4
|
+
// architecture machine) as a Sonar Generic Issue Import document (SonarQube 10.3+ format:
|
|
5
|
+
// `{ rules, issues }`), engineId `starci-hfs`, rule id = the finding code
|
|
6
|
+
// hfs report <eslint|stylelint> <in> <out>
|
|
7
|
+
// converts the linter's own json output (`eslint -f json`, `stylelint --formatter json`)
|
|
8
|
+
// into the same document format, engineId `eslint` / `stylelint`, rule id = the linter's
|
|
9
|
+
// rule. Sonar's own ESLint import (sonar.eslint.reportPaths) is not used: it drops an issue
|
|
10
|
+
// on a file outside sonar.sources, and a stylelint result has no native import at all.
|
|
11
|
+
// One placement rule for every engine: Sonar imports an issue only on a file it indexes (a tracked source or stylesheet under
|
|
12
|
+
// sonar.sources). A finding on any other path (hfs.json, a workflow, a package under an unindexed root, e2e/, a directory) is filed
|
|
13
|
+
// on the first source file of sonar.sources and its message names the real path, so no finding is dropped.
|
|
14
|
+
// The output is deterministic: rules sorted by id, issues by file, line, rule and message, so two runs over one tree are
|
|
15
|
+
// byte-identical. A finding of level `info` (report-only) is not exported: the gate fails on any imported issue.
|
|
16
|
+
import fs from 'node:fs';
|
|
17
|
+
import path from 'node:path';
|
|
18
|
+
|
|
19
|
+
export const HFS_ENGINE = 'starci-hfs';
|
|
20
|
+
export const ESLINT_ENGINE = 'eslint';
|
|
21
|
+
export const STYLELINT_ENGINE = 'stylelint';
|
|
22
|
+
/** Every imported issue is a maintainability defect of the highest impact: the gate holds the count at zero. */
|
|
23
|
+
const IMPACT = Object.freeze([Object.freeze({ softwareQuality: 'MAINTAINABILITY', severity: 'HIGH' })]);
|
|
24
|
+
const CLEAN_CODE_ATTRIBUTE = 'CONVENTIONAL';
|
|
25
|
+
const SOURCE_FILE = /\.(?:[cm]?[jt]sx?)$/;
|
|
26
|
+
/** The files Sonar indexes under sonar.sources: the source files above and the stylesheets its CSS analyzer reads. */
|
|
27
|
+
const INDEXED_FILE = /\.(?:[cm]?[jt]sx?|css)$/;
|
|
28
|
+
|
|
29
|
+
const posix = (file) => file.split(path.sep).join('/').replace(/^\.\//, '');
|
|
30
|
+
const cmp = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
|
|
31
|
+
|
|
32
|
+
const ruleOf = (id, engineId, name, description) => ({ id, name, description, engineId, cleanCodeAttribute: CLEAN_CODE_ATTRIBUTE, impacts: IMPACT.map((impact) => ({ ...impact })) });
|
|
33
|
+
|
|
34
|
+
const rangeOf = (line, endLine) => (Number.isInteger(line) && line >= 1 ? { textRange: { startLine: line, endLine: Number.isInteger(endLine) && endLine >= line ? endLine : line } } : {});
|
|
35
|
+
|
|
36
|
+
function document(rules, issues) {
|
|
37
|
+
const sorted = [...issues].sort((a, b) => cmp(a.primaryLocation.filePath, b.primaryLocation.filePath)
|
|
38
|
+
|| (a.primaryLocation.textRange?.startLine ?? 0) - (b.primaryLocation.textRange?.startLine ?? 0)
|
|
39
|
+
|| cmp(a.ruleId, b.ruleId)
|
|
40
|
+
|| cmp(a.primaryLocation.message, b.primaryLocation.message));
|
|
41
|
+
const used = new Set(sorted.map((issue) => issue.ruleId));
|
|
42
|
+
return { rules: [...rules.values()].filter((rule) => used.has(rule.id)).sort((a, b) => cmp(a.id, b.id)), issues: sorted };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The first source file of the repository (sorted) under one of `sourceRoots`: Sonar imports an issue only on a file it
|
|
47
|
+
* indexes, and a finding about hfs.json, a workflow, a config file or a directory is not on one, so it is filed on this
|
|
48
|
+
* file and its message names the real path. Null when the tracked tree has no source file.
|
|
49
|
+
*/
|
|
50
|
+
export function anchorOf(tracked, sourceRoots) {
|
|
51
|
+
const roots = sourceRoots.map((root) => `${posix(root).replace(/\/$/, '')}/`);
|
|
52
|
+
return [...tracked].map(posix).filter((file) => SOURCE_FILE.test(file) && roots.some((root) => file.startsWith(root))).sort(cmp)[0] ?? null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The one placement rule of every engine. `sourceRoots` and `tracked` say which files Sonar indexes; without them every
|
|
57
|
+
* finding keeps its own path. Returns `place(own, message, line, endLine)`: the `primaryLocation` of the finding (its own file,
|
|
58
|
+
* or the anchor with the real path named in the message), or null when there is nowhere to file it.
|
|
59
|
+
*/
|
|
60
|
+
function placement({ sourceRoots = [], tracked = [] }) {
|
|
61
|
+
const anchor = sourceRoots.length ? anchorOf(tracked, sourceRoots) : null;
|
|
62
|
+
const roots = sourceRoots.map((root) => `${posix(root).replace(/\/$/, '')}/`);
|
|
63
|
+
const trackedSet = new Set(tracked.map(posix));
|
|
64
|
+
// Sonar imports an issue only on a file it indexes: a tracked source file or stylesheet under sonar.sources (a directory, a config file or a JSON file is not one).
|
|
65
|
+
const indexed = (file) => roots.some((root) => file.startsWith(root)) && INDEXED_FILE.test(file) && (trackedSet.size === 0 || trackedSet.has(file));
|
|
66
|
+
return (own, message, line, endLine) => {
|
|
67
|
+
const moved = anchor !== null && (own === null || !indexed(own));
|
|
68
|
+
const filePath = moved ? anchor : own ?? anchor;
|
|
69
|
+
if (filePath === null) return null;
|
|
70
|
+
return { message: moved ? `${own ?? 'repository'}: ${message}` : message, filePath, ...(moved ? {} : rangeOf(line, endLine)) };
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The Generic Issue Import document of a check's findings (the `findings` of checkRepository, each with the catalog's
|
|
76
|
+
* `title`, `titleVi`, `whyVi` and `nextStepVi`). `sourceRoots` and `tracked` place a finding that lies outside the indexed
|
|
77
|
+
* sources (see placement); without them every finding keeps its own path.
|
|
78
|
+
*/
|
|
79
|
+
export function sonarReport(findings, { sourceRoots = [], tracked = [] } = {}) {
|
|
80
|
+
const place = placement({ sourceRoots, tracked });
|
|
81
|
+
const rules = new Map();
|
|
82
|
+
const issues = [];
|
|
83
|
+
for (const finding of findings) {
|
|
84
|
+
if (finding.level !== 'error') continue;
|
|
85
|
+
if (!rules.has(finding.code)) {
|
|
86
|
+
rules.set(finding.code, ruleOf(finding.code, HFS_ENGINE, finding.title ?? finding.code, `${finding.title ?? finding.code}. ${finding.titleVi}: ${finding.whyVi} Cách sửa: ${finding.nextStepVi}`));
|
|
87
|
+
}
|
|
88
|
+
const primaryLocation = place(finding.path ? posix(finding.path) : null, finding.message, finding.line);
|
|
89
|
+
if (primaryLocation === null) continue;
|
|
90
|
+
issues.push({ ruleId: finding.code, effortMinutes: 5, primaryLocation });
|
|
91
|
+
}
|
|
92
|
+
return document(rules, issues);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Text of a stylelint warning without the trailing " (rule-name)" its formatter appends. */
|
|
96
|
+
const stylelintText = (warning) => String(warning.text ?? '').replace(new RegExp(`\\s*\\(${String(warning.rule ?? '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\)$`), '');
|
|
97
|
+
|
|
98
|
+
// What differs between the two linters' json output: where the file, the findings, the rule and the text are.
|
|
99
|
+
const LINTERS = Object.freeze({
|
|
100
|
+
eslint: {
|
|
101
|
+
engine: ESLINT_ENGINE,
|
|
102
|
+
errorRule: 'eslint-error',
|
|
103
|
+
file: (result) => result.filePath,
|
|
104
|
+
findings: (result) => result.messages,
|
|
105
|
+
rule: (message) => message.ruleId,
|
|
106
|
+
text: (message) => message.message,
|
|
107
|
+
describe: (id) => `ESLint rule ${id} of the StarCi canon (@starci/eslint-canon-be and @starci/eslint-canon-fe) or of a plugin it composes.`,
|
|
108
|
+
describeError: 'ESLint could not lint a file: a parse error or an invalid configuration.',
|
|
109
|
+
shape: 'an array of results with a filePath and messages',
|
|
110
|
+
},
|
|
111
|
+
stylelint: {
|
|
112
|
+
engine: STYLELINT_ENGINE,
|
|
113
|
+
errorRule: 'stylelint-error',
|
|
114
|
+
file: (result) => result.source,
|
|
115
|
+
findings: (result) => result.warnings,
|
|
116
|
+
rule: (warning) => warning.rule,
|
|
117
|
+
text: stylelintText,
|
|
118
|
+
describe: (id) => `Stylelint rule ${id} of the StarCi CSS canon (@starci/stylelint-canon, HFS R61).`,
|
|
119
|
+
describeError: 'Stylelint could not read a stylesheet: a parse error or an invalid option.',
|
|
120
|
+
shape: 'an array of results with a source and warnings',
|
|
121
|
+
},
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
export const LINTER_KINDS = Object.freeze(Object.keys(LINTERS));
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* A linter's json results (`eslint -f json`: [{ filePath, messages: [{ ruleId, line, endLine, message }] }]; `stylelint
|
|
128
|
+
* --formatter json`: [{ source, warnings: [{ line, endLine, rule, text }] }]) as a Generic Issue Import document. `root` is
|
|
129
|
+
* the directory paths are made relative to (the repository root, where the scan runs); a file outside it is skipped.
|
|
130
|
+
* `sourceRoots` and `tracked` place a finding on a file Sonar does not index (see placement). A parse error or an invalid
|
|
131
|
+
* option is a finding the linter reports without a rule name; it is imported under the rule `eslint-error` / `stylelint-error`.
|
|
132
|
+
*/
|
|
133
|
+
export function linterReport(kind, results, { root, sourceRoots = [], tracked = [] }) {
|
|
134
|
+
const linter = LINTERS[kind];
|
|
135
|
+
if (linter === undefined) throw new Error(`unknown linter ${kind}; expected ${LINTER_KINDS.join(' or ')}`);
|
|
136
|
+
const place = placement({ sourceRoots, tracked });
|
|
137
|
+
const rules = new Map();
|
|
138
|
+
const issues = [];
|
|
139
|
+
for (const result of results) {
|
|
140
|
+
const source = String(linter.file(result) ?? '');
|
|
141
|
+
if (!source) continue;
|
|
142
|
+
const relative = posix(path.isAbsolute(source) ? path.relative(root, source) : source);
|
|
143
|
+
if (relative.startsWith('..')) continue;
|
|
144
|
+
for (const finding of linter.findings(result) ?? []) {
|
|
145
|
+
const id = linter.rule(finding) || linter.errorRule;
|
|
146
|
+
if (!rules.has(id)) rules.set(id, ruleOf(id, linter.engine, id, id === linter.errorRule ? linter.describeError : linter.describe(id)));
|
|
147
|
+
const primaryLocation = place(relative, linter.text(finding) || id, finding.line, finding.endLine);
|
|
148
|
+
if (primaryLocation !== null) issues.push({ ruleId: id, effortMinutes: 5, primaryLocation });
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return document(rules, issues);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** The `sonar.sources` roots of a sonar-project.properties text (empty when the file does not name them). */
|
|
155
|
+
export function sourceRootsOf(propertiesText) {
|
|
156
|
+
const line = String(propertiesText).split(/\r?\n/).map((raw) => raw.trim()).find((raw) => raw.startsWith('sonar.sources='));
|
|
157
|
+
return line ? line.slice('sonar.sources='.length).split(',').map((root) => root.trim()).filter(Boolean) : [];
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Write a report as JSON (2-space, LF, trailing newline), creating the directory. */
|
|
161
|
+
export function writeReport(file, report) {
|
|
162
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
163
|
+
fs.writeFileSync(file, `${JSON.stringify(report, null, 2)}\n`);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** `hfs report <eslint|stylelint> <in> <out> [--repo <dir>]`: convert a linter's json report; returns the number of issues written. */
|
|
167
|
+
export function convertReportFile({ kind, input, output, root, sourceRoots = [], tracked = [] }) {
|
|
168
|
+
const linter = LINTERS[kind];
|
|
169
|
+
if (linter === undefined) throw new Error(`unknown linter ${kind}; expected ${LINTER_KINDS.join(' or ')}`);
|
|
170
|
+
let results;
|
|
171
|
+
try {
|
|
172
|
+
results = JSON.parse(fs.readFileSync(input, 'utf8'));
|
|
173
|
+
} catch (error) {
|
|
174
|
+
throw new Error(`${input} is not a readable ${kind} json report (${error.message})`);
|
|
175
|
+
}
|
|
176
|
+
if (!Array.isArray(results)) throw new Error(`${input} is not a ${kind} json report: expected ${linter.shape}`);
|
|
177
|
+
const report = linterReport(kind, results, { root, sourceRoots, tracked });
|
|
178
|
+
writeReport(output, report);
|
|
179
|
+
return report.issues.length;
|
|
180
|
+
}
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
const PATH_LEASE_PREFIX='path:';
|
|
4
|
+
const GLOB_META=/[*?[\]{}]/;
|
|
5
|
+
// Next.js App Router spells route segments as literal directory names: dynamic `[lang]`, catch-all
|
|
6
|
+
// `[...slug]` and optional catch-all `[[...opt]]`, optionally behind an intercept prefix `(.)`, `(..)`,
|
|
7
|
+
// `(...)` or `(..)(..)`. Route groups `(group)`, parallel slots `@slot` and intercepts on a static name
|
|
8
|
+
// carry no glob meta at all. A segment of exactly this shape is a concrete name, never a character class;
|
|
9
|
+
// every consumer that hands an owned path to a glob engine escapes it (git: ownedPathspec below).
|
|
10
|
+
const APP_ROUTER_SEGMENT=/^(?:\(\.{1,3}\))*(?:\[\[\.\.\.[A-Za-z0-9_$-]+\]\]|\[(?:\.\.\.)?[A-Za-z0-9_$-]+\])$/;
|
|
11
|
+
|
|
12
|
+
/** A Next.js App Router bracket segment (`[id]`, `[...slug]`, `[[...opt]]`, `(.)[id]`) — a literal directory name. */
|
|
13
|
+
export const isAppRouterSegment=part=>APP_ROUTER_SEGMENT.test(String(part??''));
|
|
14
|
+
|
|
15
|
+
/** A path segment that is a real glob (`*`, `?`, `{a,b}`, a bare character class), not an App Router name. */
|
|
16
|
+
export const isGlobSegment=part=>GLOB_META.test(String(part??''))&&!isAppRouterSegment(part);
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The git pathspec for one concrete owned path. Git reads a plain pathspec as a glob, so `src/app/[id]`
|
|
20
|
+
* would also match a sibling `src/app/i`; `:(literal)` pins it to the named directory. Admission
|
|
21
|
+
* refuses a glob, so every owned path is literal.
|
|
22
|
+
*/
|
|
23
|
+
export const ownedPathspec=spec=>`:(literal)${String(spec??'').replace(/\\/g,'/')||'.'}`;
|
|
24
|
+
|
|
25
|
+
const plainPath=value=>typeof value==='string'?value:value?.path;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Canonical workspace-relative path prefix used by planning, leases and report boundaries. In a
|
|
29
|
+
* multi-repository project the caller includes the repository binding prefix (for example `nivo-fe/`).
|
|
30
|
+
* A directory prefix is spelled either bare (`docs/`) or with a trailing `/**`, which normalizes to
|
|
31
|
+
* the same prefix; every other glob, absolute path and parent traversal is refused because it is not
|
|
32
|
+
* a concrete ownership boundary. A Next.js App Router segment (`[lang]`, `[...slug]`, `[[...opt]]`,
|
|
33
|
+
* `(group)`, `@slot`, `(.)photo`) is a literal directory name and is admitted as one.
|
|
34
|
+
*/
|
|
35
|
+
export function normalizeOwnedPath(value){
|
|
36
|
+
let input=String(plainPath(value)??'').trim().replace(/\\/g,'/');
|
|
37
|
+
input=input.replace(/\/\*\*\/$/,'').replace(/\/\*\*$/,'');
|
|
38
|
+
if(!input||input.startsWith('/')||/^[A-Za-z]:\//.test(input))throw Error(`owned path must be repository-relative: ${JSON.stringify(plainPath(value)??value)}`);
|
|
39
|
+
const parts=[];
|
|
40
|
+
for(const part of input.split('/')){
|
|
41
|
+
if(!part||part==='.')continue;
|
|
42
|
+
if(part==='..')throw Error(`owned path must not traverse its repository: ${JSON.stringify(plainPath(value)??value)}`);
|
|
43
|
+
if(isGlobSegment(part))throw Error(`owned path must be a concrete prefix, not a glob: ${JSON.stringify(plainPath(value)??value)}`);
|
|
44
|
+
parts.push(part);
|
|
45
|
+
}
|
|
46
|
+
if(!parts.length)throw Error(`owned path must name a concrete repository-relative prefix: ${JSON.stringify(plainPath(value)??value)}`);
|
|
47
|
+
return parts.join('/');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Normalize, de-duplicate and collapse descendants already covered by an owned ancestor. */
|
|
51
|
+
export function normalizeOwnedPaths(values=[]){
|
|
52
|
+
const paths=[...new Set(values.map(normalizeOwnedPath))].sort((a,b)=>a.length-b.length||a.localeCompare(b));
|
|
53
|
+
return paths.filter((candidate,index)=>!paths.slice(0,index).some(parent=>ownedPathsIntersect(parent,candidate)));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Path-prefix overlap: equality or either concrete path being below the other. */
|
|
57
|
+
export function ownedPathsIntersect(left,right){
|
|
58
|
+
const a=normalizeOwnedPath(left),b=normalizeOwnedPath(right);
|
|
59
|
+
return a===b||a.startsWith(`${b}/`)||b.startsWith(`${a}/`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The durable resource identity for one normalized concrete owned path. */
|
|
63
|
+
export const ownedPathLeaseKey=value=>`${PATH_LEASE_PREFIX}${normalizeOwnedPath(value)}`;
|
|
64
|
+
|
|
65
|
+
/** One capacity-one request per minimal owned prefix. */
|
|
66
|
+
export const ownedPathLeaseRequests=values=>normalizeOwnedPaths(values).map(path=>({resourceKey:ownedPathLeaseKey(path),units:1}));
|
|
67
|
+
|
|
68
|
+
const leasePath=resourceKey=>String(resourceKey??'').startsWith(PATH_LEASE_PREFIX)
|
|
69
|
+
?String(resourceKey).slice(PATH_LEASE_PREFIX.length):null;
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The spelling two lease paths are compared in. The same file must compare equal however a workflow
|
|
73
|
+
* spelled it (nivo wf-nivo-fe-debt-mug06w7h inc-52a4a5ee5b12: `apps/app/src/messages/vi.json` bare for
|
|
74
|
+
* the fe repository vs `nivo-fe/apps/app/src/messages` repository-prefixed never overlapped). `canonicalOf`
|
|
75
|
+
* (scripts/kernel/lease-canon.mjs) resolves a path to its repository-qualified form
|
|
76
|
+
* `repository:<role>/<path>` — for a held row through its holder job, so a lease taken before
|
|
77
|
+
* canonical keys existed still conflicts; paths on Windows compare case-insensitively, as its file
|
|
78
|
+
* systems do.
|
|
79
|
+
*/
|
|
80
|
+
export const leaseCompareForm=(leasePathValue,{canonicalOf=null,row=null,platform=process.platform}={})=>{
|
|
81
|
+
let value=normalizeOwnedPath(leasePathValue);
|
|
82
|
+
if(canonicalOf){try{value=normalizeOwnedPath(canonicalOf(value,row)??value);}catch{/* an unresolvable spelling compares as written */}}
|
|
83
|
+
return platform==='win32'?value.toLowerCase():value;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Find durable path leases that overlap a requested parent/child prefix. Lease-row existence is the
|
|
88
|
+
* fence; expiry is only a recovery signal and does not by itself prove the prior worker has no effect.
|
|
89
|
+
* Both sides are compared in leaseCompareForm, so a bare and a repository-prefixed spelling of one
|
|
90
|
+
* file overlap and the same relative path in two repositories does not.
|
|
91
|
+
*/
|
|
92
|
+
export function findOwnedPathLeaseConflicts(db,requests,{excludeJobId=null,canonicalOf=null,platform=process.platform}={}){
|
|
93
|
+
const requested=[...new Set(requests.map(item=>item?.resourceKey??item).filter(key=>leasePath(key)!==null))];
|
|
94
|
+
if(!requested.length)return [];
|
|
95
|
+
const held=db.prepare("SELECT resource_key,job_id,workflow_id,op_id,try_no AS attempt,generation,expires_at FROM leases WHERE resource_key LIKE 'path:%' ORDER BY resource_key,job_id").all();
|
|
96
|
+
const formOf=new Map();
|
|
97
|
+
const compare=(key,row)=>{
|
|
98
|
+
const id=`${row?.job_id??''}\0${key}`;
|
|
99
|
+
if(!formOf.has(id))formOf.set(id,leaseCompareForm(leasePath(key),{canonicalOf,row,platform}));
|
|
100
|
+
return formOf.get(id);
|
|
101
|
+
};
|
|
102
|
+
const conflicts=[];
|
|
103
|
+
for(const requestKey of requested){
|
|
104
|
+
const requestPath=compare(requestKey,null);
|
|
105
|
+
for(const row of held){
|
|
106
|
+
if(excludeJobId&&row.job_id===excludeJobId)continue;
|
|
107
|
+
if(ownedPathsIntersect(requestPath,compare(row.resource_key,row)))conflicts.push({requested:requestKey,held:row.resource_key,...row});
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return conflicts;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The concurrent-operation ceiling one workflow is admitted at. Two declared numbers meet here and
|
|
115
|
+
* the LOWER of them admits: the owner's `budgets.maxOps` (per workflow) and `maxParallelOps` from
|
|
116
|
+
* modules/models/runtimes.yaml (fleet-wide). A null, absent or non-positive value is unbounded, so
|
|
117
|
+
* a workflow with no owner budget still meets the fleet ceiling. A parallelism gear raises what
|
|
118
|
+
* `api estimate` requests and never raises either of these.
|
|
119
|
+
*/
|
|
120
|
+
export function opSlotCeiling({maxOps=null,maxParallelOps=null}={}){
|
|
121
|
+
const positive=value=>{const n=Number(value);return Number.isInteger(n)&&n>0?n:null;};
|
|
122
|
+
const owner=positive(maxOps),fleet=positive(maxParallelOps);
|
|
123
|
+
if(owner===null&&fleet===null)return {ceiling:null,source:null};
|
|
124
|
+
if(owner===null)return {ceiling:fleet,source:'maxParallelOps'};
|
|
125
|
+
if(fleet===null)return {ceiling:owner,source:'budgets.maxOps'};
|
|
126
|
+
return owner<=fleet?{ceiling:owner,source:'budgets.maxOps'}:{ceiling:fleet,source:'maxParallelOps'};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Admission against that ceiling. `running` is how many operations of the one workflow already hold
|
|
131
|
+
* a slot; a job at or above the ceiling is refused `max-ops` rather than launched and left to
|
|
132
|
+
* discover the cap from a provider.
|
|
133
|
+
*/
|
|
134
|
+
export function admitOpSlot({running=0,maxOps=null,maxParallelOps=null}={}){
|
|
135
|
+
const {ceiling,source}=opSlotCeiling({maxOps,maxParallelOps});
|
|
136
|
+
const held=Math.max(0,Number(running)||0);
|
|
137
|
+
if(ceiling===null)return {ok:true,running:held,ceiling:null,ceilingSource:null,reason:null};
|
|
138
|
+
return held<ceiling
|
|
139
|
+
?{ok:true,running:held,ceiling,ceilingSource:source,reason:null}
|
|
140
|
+
:{ok:false,running:held,ceiling,ceilingSource:source,reason:'max-ops'};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const rowObject=value=>{
|
|
144
|
+
if(value&&typeof value==='object')return value;
|
|
145
|
+
if(typeof value!=='string'||!value.trim())return {};
|
|
146
|
+
try{const parsed=JSON.parse(value);return parsed&&typeof parsed==='object'?parsed:{};}catch{return {};}
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* The durable payload/result of a job-shaped row: the parsed object when the row carries the decoded
|
|
151
|
+
* field (a mapped ledger row), else the tolerant parse of its `*_json` text — a missing, blank or
|
|
152
|
+
* unparsable field reads as {}. Several scripts spell this by hand; these are the one pair to cite.
|
|
153
|
+
*/
|
|
154
|
+
export const payloadOf=job=>rowObject(job?.payload??job?.payload_json);
|
|
155
|
+
export const resultOf=job=>rowObject(job?.result??job?.result_json);
|
|
156
|
+
|
|
157
|
+
/** The settled verdict of an attempt that asked the owner and waits for the answer. */
|
|
158
|
+
export const AWAITING_OWNER='awaiting-owner';
|
|
159
|
+
/**
|
|
160
|
+
* jobs.status of a try that ended asking the owner (report outcome ask): settled, but neither a failure nor a spent try
|
|
161
|
+
* (its unit's try budget and business retries ignore it). A retry or resume may follow it exactly as it follows `failed`.
|
|
162
|
+
*/
|
|
163
|
+
export const AWAITING_OWNER_STATUS='awaiting_owner';
|
|
164
|
+
export const RETRYABLE_JOB_STATUSES=Object.freeze(['failed',AWAITING_OWNER_STATUS]);
|
|
165
|
+
/** Every jobs.status that holds nothing the runtime still needs (mirrors engine/ledger-db.mjs JOB_STATUSES.settled). */
|
|
166
|
+
export const SETTLED_JOB_LIST=Object.freeze(['succeeded','failed',AWAITING_OWNER_STATUS,'cancelled']);
|
|
167
|
+
/** The tries of a unit that spent budget: every try but the ones that only waited on the owner. */
|
|
168
|
+
export const spentTries=tries=>tries.filter(job=>job.status!==AWAITING_OWNER_STATUS).length;
|
|
169
|
+
// An attempt the environment killed with effects on the tree (a host terminal wipe: every Orca terminal
|
|
170
|
+
// gone at once, scripts/kernel/api.mjs hostTerminalWipeOf) settles failed with this retryClass: its retry
|
|
171
|
+
// is a new durable attempt that continues the partial tree and spends no business retry.
|
|
172
|
+
export const RETRY_CLASS_ENVIRONMENT='environment';
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Classify a settled attempt for retry accounting. Infrastructure is free only when the durable result
|
|
176
|
+
* explicitly proves `effectState: none`; unknown or partial effects consume the ordinary business budget.
|
|
177
|
+
* An attempt settled `awaiting-owner` asked a question and did not fail: its successor is a new durable
|
|
178
|
+
* attempt (the ask attempt ran) that spends no business retry. Nor does one settled `peerBlocked`
|
|
179
|
+
* (api settle: every red check was a peer's change, scripts/kernel/gate-attribution.mjs), nor one settled
|
|
180
|
+
* with retryClass environment (RETRY_CLASS_ENVIRONMENT).
|
|
181
|
+
*/
|
|
182
|
+
export function retryDisposition(job){
|
|
183
|
+
const result=resultOf(job),reason=String(result.reason??'');
|
|
184
|
+
const infrastructure=result.retryClass==='infrastructure'||reason==='dispatch-rejected'||reason==='provider-unavailable';
|
|
185
|
+
const explicitlyReusable=(result.retryable===true&&result.attemptConsumed===false)||result.retryClass==='infrastructure';
|
|
186
|
+
const noEffect=infrastructure&&result.effectState==='none'&&explicitlyReusable;
|
|
187
|
+
const ownerAnswer=!noEffect&&result.verdict===AWAITING_OWNER;
|
|
188
|
+
const peerBlocked=!noEffect&&!ownerAnswer&&result.verdict!=='pass'&&Boolean(result.peerBlocked&&typeof result.peerBlocked==='object');
|
|
189
|
+
const environment=!noEffect&&!ownerAnswer&&!peerBlocked&&result.retryClass===RETRY_CLASS_ENVIRONMENT&&result.attemptConsumed===false;
|
|
190
|
+
return {
|
|
191
|
+
retryClass:noEffect?'infrastructure':ownerAnswer?'owner-answer':peerBlocked?'peer-blocked':environment?RETRY_CLASS_ENVIRONMENT:'business',
|
|
192
|
+
effectState:result.effectState??'unknown',
|
|
193
|
+
resumable:noEffect,
|
|
194
|
+
consumesBusinessRetry:!noEffect&&!ownerAnswer&&!peerBlocked&&!environment,
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* A row retired while still queued - `api reconcile --drop` (result.verdict `dropped`) or a goal revision
|
|
200
|
+
* that superseded it (result.reason `goal-revision-superseded`) - with no dispatch binding in its payload.
|
|
201
|
+
* It ran nothing, so it is no attempt: never a retry predecessor, never a cut seam, never the latest job
|
|
202
|
+
* of its ordinal (inc-5005d003825a: a retry chained to a dropped ordinal-1 row as business attempt 2 and
|
|
203
|
+
* lost the owner-answer lineage; inc-b428eb47fde3: ordinal 2 read a dropped seam as dependency-failed).
|
|
204
|
+
*/
|
|
205
|
+
export function retiredBeforeDispatch(job){
|
|
206
|
+
if(job?.status!=='cancelled')return false;
|
|
207
|
+
const result=resultOf(job),payload=payloadOf(job);
|
|
208
|
+
if(result.verdict!=='dropped'&&result.reason!=='goal-revision-superseded')return false;
|
|
209
|
+
const runtime=payload.hierarchy?.runtime??{};
|
|
210
|
+
const bound=Boolean(job.worker_id||payload.managed||payload.orca||runtime.dispatchId||runtime.terminalHandle
|
|
211
|
+
||(Array.isArray(payload.rejectedDispatches)&&payload.rejectedDispatches.length));
|
|
212
|
+
return !bound;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
/** The cut slice a job row carries, or null: {id, ordinal} identify one bounded SAME-op slice. */
|
|
217
|
+
export function cutOf(job){
|
|
218
|
+
const cut=payloadOf(job).cut;
|
|
219
|
+
return cut&&cut.id!=null&&cut.ordinal!=null?{id:String(cut.id),ordinal:Number(cut.ordinal),total:Number(cut.total)}:null;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
/* ------------------------------------------------------------ work units (H3, H4, H5) */
|
|
224
|
+
|
|
225
|
+
/** The default try budget of a unit (Q13; DBTREE work_units.try_budget). Only the owner or the Supervisor raises one. */
|
|
226
|
+
export const UNIT_TRY_BUDGET=5;
|
|
227
|
+
const shortDigest=value=>createHash('sha256').update(value).digest('hex').slice(0,16);
|
|
228
|
+
const lineagePaths=list=>(Array.isArray(list)?list:[]).map(item=>typeof item==='string'?item:item?.path).filter(p=>typeof p==='string'&&p.trim());
|
|
229
|
+
const normList=list=>[...new Set(lineagePaths(list).map(p=>p.replace(/\\/g,'/').replace(/\/\*\*$/,'').replace(/\/+$/,'')))].sort();
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* The work identity of a job (DBTREE work_units.subject_key): a cut slice is `cut:<id>#<ordinal>`, an op about one
|
|
233
|
+
* named subject `subject:<s>`, else the digest of its records, else of its owned paths. One op, one subject key and
|
|
234
|
+
* one goal revision are ONE unit (UNIQUE(workflow_id, op_id, subject_key, goal_revision)): a try of the same work can
|
|
235
|
+
* never start a fresh budget.
|
|
236
|
+
*/
|
|
237
|
+
export function unitSubjectKey({cut=null,params=null,records=[],ownedPaths=[]}={}){
|
|
238
|
+
if(cut?.id!=null&&cut?.ordinal!=null)return `cut:${cut.id}#${Number(cut.ordinal)}`;
|
|
239
|
+
const subject=typeof params?.subject==='string'&¶ms.subject.trim()?params.subject.trim():null;
|
|
240
|
+
if(subject)return `subject:${subject}`;
|
|
241
|
+
const recs=normList(records);
|
|
242
|
+
if(recs.length)return `records:${shortDigest(recs.join('|'))}`;
|
|
243
|
+
return `paths:${shortDigest(normList(ownedPaths).join('|'))}`;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** Two jobs are tries of one work unit (jobs.unit_id). */
|
|
247
|
+
export const sameUnit=(a,b)=>Boolean(a?.unit_id&&a.unit_id===b?.unit_id);
|
|
248
|
+
|
|
249
|
+
const OPEN_TRY=['queued','ready','leased','running','answering','reported','deciding','effect_unknown'];
|
|
250
|
+
const refuseUnit=(message,code,extra={})=>Object.assign(new Error(message),{code,...extra});
|
|
251
|
+
/** The jobs.retry_class of a successor of `last` (DBTREE: business | infra | resume | follow-up). */
|
|
252
|
+
const retryClassOf=(last,disposition)=>last.status==='cancelled'?'resume'
|
|
253
|
+
:disposition?.retryClass==='business'?'business'
|
|
254
|
+
:disposition?.retryClass==='infrastructure'||disposition?.retryClass===RETRY_CLASS_ENVIRONMENT?'infra':'follow-up';
|
|
255
|
+
/**
|
|
256
|
+
* Admit one more try of a unit (the code side of DBTREE jobs_enqueue_guard + work_units_done_guard), pure over the
|
|
257
|
+
* unit's row and its tries. `tries` are the unit's jobs with {job_id, status, try_no, result_json?} (result_json the
|
|
258
|
+
* settle result retryDisposition reads); `unit` is the work_units row. Returns {tryNo, retryOf, resumeOf, retryClass,
|
|
259
|
+
* reopen} or throws a typed refusal:
|
|
260
|
+
* unit-in-flight a try of the unit is still open (edit it, or let it settle first)
|
|
261
|
+
* unit-already-passed the unit is done; a re-run needs an explicit reopen with a reason (H5)
|
|
262
|
+
* retry-lineage-invalid retryOf is not the unit's latest try, or that try did not fail (H4)
|
|
263
|
+
* unit-try-budget-exhausted try_no would pass work_units.try_budget; only the owner or the Supervisor raises it (H3)
|
|
264
|
+
*/
|
|
265
|
+
export function admitUnitTry({unit=null,tries=[],retryOf=null,reopen=null}={}){
|
|
266
|
+
const ordered=[...tries].sort((a,b)=>Number(a.try_no)-Number(b.try_no));
|
|
267
|
+
const last=ordered.at(-1)??null;
|
|
268
|
+
if(!unit||!last){
|
|
269
|
+
if(retryOf)throw refuseUnit(`--retry-of ${retryOf} names no earlier try of this unit`,'retry-lineage-invalid');
|
|
270
|
+
return {tryNo:1,retryOf:null,resumeOf:null,retryClass:null,reopen:null};
|
|
271
|
+
}
|
|
272
|
+
const open=ordered.filter(job=>OPEN_TRY.includes(job.status));
|
|
273
|
+
if(open.length)throw refuseUnit(`unit ${unit.unit_id} already has an open try ${open.map(j=>`${j.job_id} (${j.status})`).join(', ')}: edit that try (api graph-edit widen|params) or let it settle`,'unit-in-flight',{open:open.map(j=>j.job_id)});
|
|
274
|
+
if(retryOf&&retryOf!==last.job_id)throw refuseUnit(`--retry-of ${retryOf} is not the latest try of unit ${unit.unit_id} (${last.job_id} is): a retry follows the unit's latest failed try`,'retry-lineage-invalid',{latest:last.job_id});
|
|
275
|
+
const done=unit.state==='done'||last.status==='succeeded';
|
|
276
|
+
if(done&&!(reopen?.reason&&reopen?.by))throw refuseUnit(`unit ${unit.unit_id} already passed (${last.job_id}); running it again needs an explicit reopen with a reason (--reopen <reason>)`,'unit-already-passed',{passed:last.job_id});
|
|
277
|
+
if(retryOf&&!done&&!RETRYABLE_JOB_STATUSES.includes(last.status))throw refuseUnit(`--retry-of ${retryOf} is ${last.status}: a retry follows a FAILED or awaiting_owner try of the same unit`,'retry-lineage-invalid');
|
|
278
|
+
const tryNo=Number(last.try_no)+1;
|
|
279
|
+
if(tryNo-(ordered.length-spentTries(ordered))>Number(unit.try_budget))throw refuseUnit(`unit ${unit.unit_id} spent ${spentTries(ordered)} of its ${unit.try_budget} tries: the owner or the Supervisor decides (api unit --raise-budget), never another try`,'unit-try-budget-exhausted',{tries:Number(last.try_no),budget:Number(unit.try_budget)});
|
|
280
|
+
if(done)return {tryNo,retryOf:null,resumeOf:null,retryClass:'follow-up',reopen:{reason:String(reopen.reason),by:String(reopen.by)}};
|
|
281
|
+
const disposition=RETRYABLE_JOB_STATUSES.includes(last.status)?retryDisposition(last):null;
|
|
282
|
+
const resume=last.status==='cancelled';
|
|
283
|
+
return {tryNo,retryOf:resume?null:last.job_id,resumeOf:resume?last.job_id:null,retryClass:retryClassOf(last,disposition),reopen:null};
|
|
284
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// digest.mjs — the one SHA-256 the runtime hashes with. A leaf: every file's integrity digest,
|
|
2
|
+
// every ledger input ref and every stamped asset is this function, so a byte stream and a file
|
|
3
|
+
// never disagree about what their digest is.
|
|
4
|
+
import { createHash } from 'node:crypto';
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
|
|
7
|
+
/** The lowercase hex SHA-256 of `value` (string, Buffer or TypedArray). */
|
|
8
|
+
export const sha256 = (value) => createHash('sha256').update(value).digest('hex');
|
|
9
|
+
/** The SHA-256 of the file's exact bytes; throws when the file cannot be read. */
|
|
10
|
+
export const sha256File = (file) => sha256(fs.readFileSync(file));
|