@starci/hfs 4.0.10 → 4.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +1 -1
- package/emit/operations.mjs +1 -1
- package/emit/type-schema.mjs +2 -1
- package/lint/run.mjs +3 -2
- package/package.json +1 -1
- package/report/order.mjs +2 -0
- package/runtime/config.example.yaml +187 -0
- package/runtime/engine/by-code-unit.mjs +12 -0
- package/runtime/engine/config.mjs +221 -239
- package/runtime/engine/invalid-config.mjs +5 -5
- package/runtime/engine/model-config.mjs +90 -0
- package/runtime/engine/orca-config.mjs +3 -1
- package/runtime/engine/removed-vocabulary.mjs +28 -0
- package/runtime/engine/resources-config.mjs +52 -0
- package/runtime/engine/runtime-root.mjs +17 -0
- package/runtime/engine/secrets.mjs +35 -31
- package/runtime/engine/sonar-config.mjs +21 -0
- package/runtime/engine/temp-root.mjs +32 -0
- package/runtime/knowledge/hfs/canon-pins.yaml +14 -14
- package/runtime/knowledge/hfs/peer-integrations.yaml +2 -3
- package/runtime/knowledge/hfs/rules.yaml +98 -65
- package/runtime/knowledge/hfs/slots.yaml +11 -14
- package/runtime/knowledge/patterns/be/api.yaml +7 -24
- package/runtime/knowledge/patterns/be/cli.yaml +3 -12
- package/runtime/knowledge/patterns/be/realtime.yaml +1 -14
- package/runtime/knowledge/patterns/be/webhooks.yaml +0 -19
- package/runtime/modules/kernel/failure-codes.yaml +1 -41
- package/runtime/modules/kernel/removed-vocabulary.yaml +168 -0
- package/runtime/modules/models/registry.yaml +25 -359
- package/runtime/modules/models/runtimes.yaml +92 -256
- package/runtime/modules/models/tiers.yaml +65 -0
- package/runtime/scripts/api/fs/claim-file.mjs +30 -0
- package/runtime/scripts/api/fs/ensure-temp-root.mjs +27 -0
- package/runtime/scripts/api/fs/forbidden-root.mjs +2 -1
- package/runtime/scripts/api/fs/make-temp-dir.mjs +11 -0
- package/runtime/scripts/api/fs/safe-remove.mjs +52 -35
- package/runtime/scripts/api/git/lib.mjs +34 -1
- package/runtime/scripts/api/process/resolve-real-tool.mjs +14 -8
- package/runtime/scripts/api/process/run-program.mjs +2 -1
- package/runtime/scripts/api/sops/lib.mjs +198 -120
- package/runtime/scripts/hfs/allows.mjs +3 -3
- package/runtime/scripts/hfs/architecture/ast-walks.mjs +63 -50
- package/runtime/scripts/hfs/architecture/automatic-gates.mjs +96 -0
- package/runtime/scripts/hfs/architecture/backend.mjs +261 -175
- package/runtime/scripts/hfs/architecture/background-unowned.mjs +69 -34
- package/runtime/scripts/hfs/architecture/client-reaches-server.mjs +55 -54
- package/runtime/scripts/hfs/architecture/clones.mjs +179 -106
- package/runtime/scripts/hfs/architecture/config-unread.mjs +5 -22
- package/runtime/scripts/hfs/architecture/config.mjs +95 -80
- package/runtime/scripts/hfs/architecture/connection-map.mjs +233 -141
- package/runtime/scripts/hfs/architecture/constructor-deps.mjs +15 -9
- package/runtime/scripts/hfs/architecture/context-coupling.mjs +76 -52
- package/runtime/scripts/hfs/architecture/context-map.mjs +215 -141
- package/runtime/scripts/hfs/architecture/context-owner.mjs +39 -30
- package/runtime/scripts/hfs/architecture/context-platform-tables.mjs +31 -17
- package/runtime/scripts/hfs/architecture/context-transaction.mjs +38 -25
- package/runtime/scripts/hfs/architecture/contract-fixture-guard.mjs +86 -40
- package/runtime/scripts/hfs/architecture/contracts-readonly.mjs +342 -0
- package/runtime/scripts/hfs/architecture/contracts.mjs +263 -455
- package/runtime/scripts/hfs/architecture/cross-app-duplicate.mjs +43 -34
- package/runtime/scripts/hfs/architecture/dead-exports.mjs +201 -154
- package/runtime/scripts/hfs/architecture/default-deny.mjs +118 -93
- package/runtime/scripts/hfs/architecture/doc-language.mjs +2 -1
- package/runtime/scripts/hfs/architecture/entrypoint.mjs +45 -30
- package/runtime/scripts/hfs/architecture/error-codes.mjs +19 -12
- package/runtime/scripts/hfs/architecture/error-masked.mjs +39 -26
- package/runtime/scripts/hfs/architecture/fe-slot-allows.mjs +2 -1
- package/runtime/scripts/hfs/architecture/feature-shape.mjs +26 -20
- package/runtime/scripts/hfs/architecture/framework-pinned.mjs +1 -1
- package/runtime/scripts/hfs/architecture/frontend-grammar.mjs +190 -0
- package/runtime/scripts/hfs/architecture/frontend-routing.mjs +186 -0
- package/runtime/scripts/hfs/architecture/frontend-world-render.mjs +311 -0
- package/runtime/scripts/hfs/architecture/frontend-world.mjs +328 -0
- package/runtime/scripts/hfs/architecture/frontend.mjs +109 -829
- package/runtime/scripts/hfs/architecture/hfs-graph.mjs +14 -2
- package/runtime/scripts/hfs/architecture/hfs.mjs +120 -288
- package/runtime/scripts/hfs/architecture/hooks-are-hooks.mjs +46 -26
- package/runtime/scripts/hfs/architecture/i18n-keys.mjs +114 -76
- package/runtime/scripts/hfs/architecture/index.mjs +158 -102
- package/runtime/scripts/hfs/architecture/injection-token-exported.mjs +33 -28
- package/runtime/scripts/hfs/architecture/machine-ast.mjs +180 -144
- package/runtime/scripts/hfs/architecture/managed-scripts.mjs +8 -2
- package/runtime/scripts/hfs/architecture/module-per-transport.mjs +150 -101
- package/runtime/scripts/hfs/architecture/next-data-contract.mjs +51 -43
- package/runtime/scripts/hfs/architecture/next-data-key.mjs +338 -0
- package/runtime/scripts/hfs/architecture/next-data.mjs +179 -427
- package/runtime/scripts/hfs/architecture/owners.mjs +27 -13
- package/runtime/scripts/hfs/architecture/package-shape.mjs +51 -23
- package/runtime/scripts/hfs/architecture/presentation.mjs +165 -0
- package/runtime/scripts/hfs/architecture/reachability.mjs +127 -61
- package/runtime/scripts/hfs/architecture/register-once.mjs +112 -63
- package/runtime/scripts/hfs/architecture/registration.mjs +187 -110
- package/runtime/scripts/hfs/architecture/required-files.mjs +100 -68
- package/runtime/scripts/hfs/architecture/route-files-thin.mjs +83 -58
- package/runtime/scripts/hfs/architecture/schema-owner.mjs +182 -108
- package/runtime/scripts/hfs/architecture/source-names-shape.mjs +318 -0
- package/runtime/scripts/hfs/architecture/source-names.mjs +83 -248
- package/runtime/scripts/hfs/architecture/sql-owner.mjs +175 -118
- package/runtime/scripts/hfs/architecture/sql-returning.mjs +39 -28
- package/runtime/scripts/hfs/architecture/sql-tokens.mjs +302 -172
- package/runtime/scripts/hfs/architecture/supabase-ast.mjs +1 -1
- package/runtime/scripts/hfs/architecture/supabase-be.mjs +55 -45
- package/runtime/scripts/hfs/architecture/supabase-results.mjs +182 -0
- package/runtime/scripts/hfs/architecture/supabase-tables.mjs +20 -12
- package/runtime/scripts/hfs/architecture/supabase.mjs +191 -263
- package/runtime/scripts/hfs/architecture/symbols.mjs +175 -99
- package/runtime/scripts/hfs/architecture/test-world-files.mjs +82 -41
- package/runtime/scripts/hfs/architecture/tiers.mjs +120 -69
- package/runtime/scripts/hfs/architecture/transport-owner.mjs +84 -57
- package/runtime/scripts/hfs/architecture/type-context.mjs +282 -0
- package/runtime/scripts/hfs/architecture/typescript.mjs +116 -276
- package/runtime/scripts/hfs/architecture/unit-spec-providers.mjs +84 -62
- package/runtime/scripts/hfs/architecture.mjs +3 -4
- package/runtime/scripts/hfs/check.mjs +61 -70
- package/runtime/scripts/hfs/coverage-scope.mjs +84 -38
- package/runtime/scripts/hfs/declaration-shape.mjs +77 -36
- package/runtime/scripts/hfs/declaration-slots.mjs +1 -1
- package/runtime/scripts/hfs/edition-slots.mjs +5 -3
- package/runtime/scripts/hfs/linear-text.mjs +30 -0
- package/runtime/scripts/hfs/manifest-shape.mjs +164 -63
- package/runtime/scripts/hfs/path-findings.mjs +58 -38
- package/runtime/scripts/hfs/pin-findings.mjs +31 -0
- package/runtime/scripts/hfs/repo-identity.mjs +5 -2
- package/runtime/scripts/hfs/rule-catalog.mjs +194 -0
- package/runtime/scripts/hfs/rule-params-shape.mjs +33 -27
- package/runtime/scripts/hfs/rules/cli.mjs +5 -1
- package/runtime/scripts/hfs/rules/contract-compat.mjs +40 -22
- package/runtime/scripts/hfs/rules/contract.mjs +35 -27
- package/runtime/scripts/hfs/rules/database-config.mjs +57 -32
- package/runtime/scripts/hfs/rules/database-migrations.mjs +52 -31
- package/runtime/scripts/hfs/rules/database-plpgsql.mjs +106 -91
- package/runtime/scripts/hfs/rules/database-sql.mjs +254 -173
- package/runtime/scripts/hfs/rules/database.mjs +36 -23
- package/runtime/scripts/hfs/rules/deps.mjs +64 -32
- package/runtime/scripts/hfs/rules/docker.mjs +113 -65
- package/runtime/scripts/hfs/rules/edition.mjs +118 -111
- package/runtime/scripts/hfs/rules/event-bus.mjs +91 -45
- package/runtime/scripts/hfs/rules/fe-contract-documents.mjs +17 -16
- package/runtime/scripts/hfs/rules/fe-no-tests.mjs +36 -16
- package/runtime/scripts/hfs/rules/frontend-tree.mjs +4 -3
- package/runtime/scripts/hfs/rules/integration-specs.mjs +75 -41
- package/runtime/scripts/hfs/rules/kinds.mjs +59 -25
- package/runtime/scripts/hfs/rules/lint-suppression.mjs +9 -3
- package/runtime/scripts/hfs/rules/monorepo.mjs +82 -45
- package/runtime/scripts/hfs/rules/peer-integrations.mjs +24 -3
- package/runtime/scripts/hfs/rules/pipeline.mjs +58 -9
- package/runtime/scripts/hfs/rules/proof-commands.mjs +14 -8
- package/runtime/scripts/hfs/rules/repo-local-checks.mjs +11 -6
- package/runtime/scripts/hfs/rules/saga.mjs +128 -61
- package/runtime/scripts/hfs/rules/secrets.mjs +4 -3
- package/runtime/scripts/hfs/rules/services.mjs +34 -16
- package/runtime/scripts/hfs/rules/stacks.mjs +21 -14
- package/runtime/scripts/hfs/rules/supabase-secrets.mjs +32 -23
- package/runtime/scripts/hfs/rules/test-topology.mjs +25 -11
- package/runtime/scripts/hfs/secret.mjs +59 -48
- package/runtime/scripts/hfs/slot-app-view.mjs +102 -0
- package/runtime/scripts/hfs/slot-classify.mjs +65 -0
- package/runtime/scripts/hfs/slot-errors.mjs +11 -0
- package/runtime/scripts/hfs/slot-imports.mjs +76 -0
- package/runtime/scripts/hfs/slot-manifest-shape.mjs +73 -0
- package/runtime/scripts/hfs/slot-match.mjs +153 -0
- package/runtime/scripts/hfs/slot-path.mjs +8 -0
- package/runtime/scripts/hfs/slot-required.mjs +76 -0
- package/runtime/scripts/hfs/slot-semantic-problems.mjs +148 -0
- package/runtime/scripts/hfs/slot-side-problems.mjs +28 -0
- package/runtime/scripts/hfs/slots.mjs +72 -693
- package/runtime/scripts/hfs/sql/pg-parse.mjs +5 -2
- package/runtime/scripts/hfs/trailing-slashes.mjs +8 -0
- package/runtime/scripts/hfs/tree.mjs +19 -14
- package/runtime/scripts/hfs/typescript-programs.mjs +6 -4
- package/runtime/scripts/hfs/view.mjs +1 -1
- package/runtime/scripts/lib/dockerfile.mjs +56 -33
- package/runtime/scripts/lib/event-contract.mjs +17 -13
- package/runtime/scripts/lib/git.mjs +2 -2
- package/runtime/scripts/lib/graphql-contract.mjs +158 -322
- package/runtime/scripts/lib/graphql-sdl.mjs +262 -0
- package/runtime/scripts/lib/i18n.mjs +3 -2
- package/runtime/scripts/lib/in-order.mjs +71 -0
- package/runtime/scripts/lib/language.mjs +3 -3
- package/runtime/scripts/lib/list.mjs +8 -1
- package/runtime/scripts/lib/path-key.mjs +38 -5
- package/runtime/scripts/lib/pid-alive.mjs +7 -0
- package/runtime/scripts/lib/regex.mjs +1 -1
- package/runtime/scripts/lib/same-text.mjs +1 -1
- package/runtime/scripts/lib/secret-patterns.mjs +6 -2
- package/runtime/scripts/lib/sleep-sync.mjs +2 -2
- package/runtime/scripts/lib/sops-envelope.mjs +95 -48
- package/runtime/scripts/lib/stack-services.mjs +1 -2
- package/runtime/scripts/lib/ts-ast.mjs +9 -0
- package/runtime/scripts/lib/walk.mjs +6 -1
- package/scaffold/add-cli-lite.mjs +2 -1
- package/scaffold/add-table.mjs +2 -2
- package/scaffold/add.mjs +3 -2
- package/scaffold/app.mjs +4 -2
- package/scaffold/edition-gate.mjs +25 -25
- package/scaffold/lite-exports.mjs +2 -1
- package/scaffold/service.mjs +5 -4
- package/sync/hygiene.mjs +15 -9
- package/sync/index.mjs +7 -3
- package/templates/app/ci-workflows/github/workflows/ci.yml +4 -4
- package/templates/app/ci-workflows/github/workflows/e2e.yml +3 -3
- package/templates/app/ci-workflows/github/workflows/images.yml +3 -3
- package/templates/app/ci-workflows/sonar-steps.yml +2 -2
- package/templates/app/ci-workflows-lite/github/workflows/ci.yml +6 -6
- package/templates/app/ci-workflows-lite/github/workflows/db-deploy.yml +3 -3
- package/templates/app/ci-workflows-lite/github/workflows/images.yml +3 -3
- package/templates/app/starciwork.gitignore +1 -1
- package/templates/be/image/api/Dockerfile +1 -1
- package/templates/be/image/cli/Dockerfile +1 -1
- package/templates/be/image/worker/Dockerfile +1 -1
- package/templates/be/patterns/cli/group.cli.spec.ts.tpl +1 -1
- package/templates/be/patterns/cli/group.cli.ts.tpl +2 -2
- package/templates/be/patterns/event-bus/platform/event-runner.service.spec.ts.tpl +13 -0
- package/templates/be/patterns/event-bus/platform/event-runner.service.ts.tpl +7 -2
- package/templates/be/patterns/event-bus/platform/event.policy.ts.tpl +4 -1
- package/templates/be/patterns/event-bus/platform/kafka-event-transport.client.ts.tpl +2 -1
- package/templates/be/patterns/outbox/platform/outbox-relay.policy.ts.tpl +24 -9
- package/templates/be/patterns/queues/platform/queue-relay.service.ts.tpl +3 -2
- package/templates/be/patterns/queues/platform/queue-worker.service.ts.tpl +8 -5
- package/templates/be/skeleton/src/features/cli/migrate/migrate.cli.spec.ts +1 -1
- package/templates/be/skeleton/src/features/cli/migrate/migrate.cli.ts +2 -2
- package/templates/be/skeleton/src/features/cli/seed/seed.cli.spec.ts +1 -1
- package/templates/be/skeleton/src/features/cli/seed/seed.cli.ts +2 -2
- package/templates/be/skeleton/src/modules/platform/database/migrate-connections.client.ts +3 -2
- package/templates/be/skeleton/src/modules/platform/database/seed-connections.client.ts +5 -4
- package/templates/be/skeleton/src/modules/platform/primitives/index.ts +1 -0
- package/templates/be/skeleton/src/modules/platform/primitives/sequence.policy.spec.ts +47 -0
- package/templates/be/skeleton/src/modules/platform/primitives/sequence.policy.ts +16 -0
- package/templates/be/skeleton-lite/apps/api/Dockerfile +1 -0
- package/templates/be/skeleton-lite/src/modules/platform/primitives/index.ts +2 -0
- package/templates/fe/image/next/Dockerfile +2 -1
- package/templates/fe/skeleton/apps/app/src/app/[locale]/page.tsx +2 -2
- package/templates/fe/skeleton/apps/landing/src/app/[locale]/page.tsx +2 -2
- package/templates/fe/skeleton/packages/__project__-i18n/src/index.ts +1 -2
- package/templates/fe/skeleton-lite/apps/web/src/app/[locale]/layout.tsx +1 -2
- package/templates/fe/skeleton-lite/apps/web/src/app/[locale]/page.tsx +2 -2
- package/templates/fe/skeleton-lite/apps/web/src/components/blocks/SignInForm/index.tsx +7 -1
- package/templates/fe/skeleton-lite/apps/web/src/modules/db/auth/write-sign-out.ts +1 -1
- package/templates/fe/skeleton-lite/apps/web/src/modules/db/validation/validation.mapper.ts +10 -2
- package/templates/fe/skeleton-lite/apps/web/src/modules/i18n/request.ts +10 -3
- package/upgrade/index.mjs +9 -2
- package/runtime/engine/admission.mjs +0 -308
- package/runtime/engine/canonical-json.mjs +0 -10
- package/runtime/engine/db/blob.mjs +0 -315
- package/runtime/engine/db/ledger-paths.mjs +0 -83
- package/runtime/engine/db/ledger.mjs +0 -1118
- package/runtime/engine/db/machine-connection.mjs +0 -135
- package/runtime/engine/db/machine-schema.mjs +0 -84
- package/runtime/engine/db/machine.mjs +0 -1367
- package/runtime/engine/db/migrations/machine/0001-init.sql +0 -924
- package/runtime/engine/db/migrations/runtime/0001-init.sql +0 -1108
- package/runtime/engine/db/provider-reservations.mjs +0 -101
- package/runtime/engine/digest.mjs +0 -16
- package/runtime/engine/refuse.mjs +0 -11
- package/runtime/modules/ops/_labels.yaml +0 -53
- package/runtime/scripts/api/node/lib.mjs +0 -14
- package/runtime/scripts/api/node/spawn-node.mjs +0 -6
- package/runtime/scripts/api/process/lib.mjs +0 -111
- package/runtime/scripts/api/process/owned-process.mjs +0 -78
- package/runtime/scripts/api/process/stop-owned-process.mjs +0 -9
- package/runtime/scripts/connectors/lib.mjs +0 -488
- package/runtime/scripts/lib/clip.mjs +0 -19
- package/runtime/scripts/lib/display-names.mjs +0 -259
- package/runtime/scripts/lib/example-refs.mjs +0 -158
- package/runtime/scripts/lib/json-schema.mjs +0 -52
- package/runtime/scripts/lib/process-identity.mjs +0 -6
- package/runtime/scripts/lib/read-yaml.mjs +0 -18
- package/runtime/scripts/lib/redact.mjs +0 -161
- package/runtime/scripts/lib/sleep.mjs +0 -7
- package/runtime/scripts/lib/source-phrases.mjs +0 -34
- package/runtime/scripts/lib/sqlite.mjs +0 -21
|
@@ -20,70 +20,105 @@ import { machineKit } from './machine-ast.mjs';
|
|
|
20
20
|
export const BACKGROUND_UNOWNED_RULE_IDS = ['BE_BACKGROUND_UNOWNED'];
|
|
21
21
|
|
|
22
22
|
const RULE = 'BE_BACKGROUND_UNOWNED';
|
|
23
|
-
const BACKGROUND_ROLES = ['processor', 'consumer'];
|
|
23
|
+
const BACKGROUND_ROLES = new Set(['processor', 'consumer']);
|
|
24
24
|
/** The words the law names for background methods (knowledge/hfs/README.md 5.7). */
|
|
25
25
|
const BACKGROUND_WORDS = ['sweep', 'deliver', 'reconcile', 'retry'];
|
|
26
26
|
|
|
27
27
|
const roleOf = rel => {
|
|
28
28
|
const parts = path.posix.basename(rel).split('.');
|
|
29
|
-
return parts.length >= 3 && parts.at(-1) === 'ts' && BACKGROUND_ROLES.
|
|
29
|
+
return parts.length >= 3 && parts.at(-1) === 'ts' && BACKGROUND_ROLES.has(parts.at(-2)) ? parts.at(-2) : null;
|
|
30
30
|
};
|
|
31
31
|
const isBackgroundName = name => BACKGROUND_WORDS.some(word => name === word || (name.startsWith(word) && /[A-Z]/u.test(name[word.length] ?? '')));
|
|
32
32
|
|
|
33
|
-
|
|
34
|
-
const { graph } = input;
|
|
35
|
-
const kit = machineKit(input);
|
|
36
|
-
const { ts, resolver } = kit;
|
|
37
|
-
const violations = [];
|
|
38
|
-
const report = (file, node, message, extra = {}) => violations.push({ ruleId: RULE, path: file.rel, ...kit.at(file.rel, file.sourceFile, node), message, ...extra });
|
|
39
|
-
|
|
33
|
+
const adjacencyOf = edges => {
|
|
40
34
|
const adjacency = new Map();
|
|
41
|
-
for (const edge of
|
|
35
|
+
for (const edge of edges) {
|
|
36
|
+
if (!edge.runtime) continue;
|
|
42
37
|
if (!adjacency.has(edge.from)) adjacency.set(edge.from, []);
|
|
43
38
|
adjacency.get(edge.from).push(edge.to);
|
|
44
39
|
}
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
const queue = [...starts];
|
|
48
|
-
while (queue.length) for (const next of adjacency.get(queue.shift()) ?? []) if (!seen.has(next)) { seen.add(next); queue.push(next); }
|
|
49
|
-
return seen;
|
|
50
|
-
};
|
|
40
|
+
return adjacency;
|
|
41
|
+
};
|
|
51
42
|
|
|
43
|
+
const closureOf = (adjacency, starts) => {
|
|
44
|
+
const seen = new Set(starts);
|
|
45
|
+
const queue = [...starts];
|
|
46
|
+
while (queue.length) {
|
|
47
|
+
for (const next of adjacency.get(queue.shift()) ?? []) {
|
|
48
|
+
if (seen.has(next)) continue;
|
|
49
|
+
seen.add(next);
|
|
50
|
+
queue.push(next);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return seen;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const appRootsOf = (apps, graph) => {
|
|
52
57
|
const rootOf = app => `apps/${app.name}/src/app.module.ts`;
|
|
53
|
-
const
|
|
54
|
-
const
|
|
55
|
-
|
|
56
|
-
|
|
58
|
+
const workers = apps.filter(app => app.kind === 'worker').map(rootOf).filter(rel => graph.files.has(rel));
|
|
59
|
+
const services = apps.filter(app => app.kind === 'api').map(rootOf).filter(rel => graph.files.has(rel));
|
|
60
|
+
return { workers, composed: [...workers, ...services] };
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const backgroundFilesOf = graph => [...graph.files.values()].filter(file => roleOf(file.rel) && file.tier === 'feature');
|
|
64
|
+
|
|
65
|
+
const reportUncomposedFiles = (background, composed, workerRoots, report) => {
|
|
57
66
|
for (const file of background) {
|
|
58
67
|
if (composed.has(file.rel)) continue;
|
|
59
68
|
const role = roleOf(file.rel);
|
|
60
69
|
report(file, file.sourceFile, `${file.rel} is a ${role} that no service app composes${workerRoots.length ? '' : ' (hfs.json declares no worker app)'}; a ${role} runs only when the ${role === 'processor' ? 'job' : 'message'} module of its feature is imported by the root module of an app of kind worker or api.`, { role });
|
|
61
70
|
}
|
|
71
|
+
};
|
|
62
72
|
|
|
63
|
-
|
|
73
|
+
const rootsOfRunningFeatures = (running, files) => {
|
|
64
74
|
const roots = new Set();
|
|
65
75
|
for (const file of running) {
|
|
66
76
|
roots.add(file.rel);
|
|
67
77
|
const owner = file.owner?.root;
|
|
68
|
-
if (owner) for (const other of
|
|
78
|
+
if (owner) for (const other of files.values()) if (other.owner?.root === owner) roots.add(other.rel);
|
|
69
79
|
}
|
|
70
|
-
|
|
80
|
+
return roots;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const backgroundMethodsOf = (node, kit, ts) => node.members.flatMap(member => {
|
|
84
|
+
if (!ts.isMethodDeclaration(member) || !member.name) return [];
|
|
85
|
+
const name = kit.propertyNameText(member.name);
|
|
86
|
+
return name && isBackgroundName(name) ? [{ name, node: member.name }] : [];
|
|
87
|
+
});
|
|
71
88
|
|
|
89
|
+
const checkBackgroundMethods = ({ graph, kit, ts, reachable, report }) => {
|
|
72
90
|
let methods = 0;
|
|
91
|
+
const reportMethod = (file, method) => {
|
|
92
|
+
methods += 1;
|
|
93
|
+
if (!reachable.has(file.rel)) report(file, method.node, `${method.name} is background work (a ${BACKGROUND_WORDS.join(', ')} method) that no processor or consumer composed by a worker or api app can reach. Add a processor in features/jobs/<job> or a consumer in transport/message of a feature that runs it, and compose its module in a worker or api app.`, { method: method.name });
|
|
94
|
+
};
|
|
73
95
|
for (const file of graph.files.values()) {
|
|
74
|
-
|
|
96
|
+
if (file.tier === 'platform') continue;
|
|
75
97
|
kit.walk(file.sourceFile, node => {
|
|
76
|
-
if (
|
|
77
|
-
for (const member of node.members) {
|
|
78
|
-
if (!ts.isMethodDeclaration(member) || !member.name) continue;
|
|
79
|
-
const name = kit.propertyNameText(member.name);
|
|
80
|
-
if (!name || !isBackgroundName(name)) continue;
|
|
81
|
-
methods += 1;
|
|
82
|
-
if (!reachable.has(file.rel)) report(file, member.name, `${name} is background work (a ${BACKGROUND_WORDS.join(', ')} method) that no processor or consumer composed by a worker or api app can reach. Add a processor in features/jobs/<job> or a consumer in transport/message of a feature that runs it, and compose its module in a worker or api app.`, { method: name });
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
return true;
|
|
98
|
+
if (ts.isClassDeclaration(node)) for (const method of backgroundMethodsOf(node, kit, ts)) reportMethod(file, method);
|
|
86
99
|
});
|
|
87
100
|
}
|
|
101
|
+
return methods;
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
export function checkBackgroundUnowned(input) {
|
|
105
|
+
const { graph } = input;
|
|
106
|
+
const kit = machineKit(input);
|
|
107
|
+
const { ts, resolver } = kit;
|
|
108
|
+
const violations = [];
|
|
109
|
+
const report = (file, node, message, extra = {}) => violations.push({ ruleId: RULE, path: file.rel, ...kit.at(file.rel, file.sourceFile, node), message, ...extra });
|
|
110
|
+
|
|
111
|
+
const adjacency = adjacencyOf(graph.edges);
|
|
112
|
+
const closure = starts => closureOf(adjacency, starts);
|
|
113
|
+
const { workers: workerRoots, composed: appRoots } = appRootsOf(resolver.repo.apps, graph);
|
|
114
|
+
const composed = closure(appRoots);
|
|
115
|
+
const background = backgroundFilesOf(graph);
|
|
116
|
+
const running = background.filter(file => composed.has(file.rel));
|
|
117
|
+
reportUncomposedFiles(background, composed, workerRoots, report);
|
|
118
|
+
|
|
119
|
+
// Everything a composed processor or consumer can reach: its imports, and the files of its own feature (command handlers).
|
|
120
|
+
const roots = rootsOfRunningFeatures(running, graph.files);
|
|
121
|
+
const reachable = closure([...roots]);
|
|
122
|
+
const methods = checkBackgroundMethods({ graph, kit, ts, reachable, report });
|
|
88
123
|
return { violations, coverage: { status: 'checked', workers: workerRoots.length, jobsAndConsumers: background.length, composed: running.length, backgroundMethods: methods } };
|
|
89
124
|
}
|
|
@@ -22,41 +22,61 @@ const SERVER_MODULES = new Set(['server-only', 'next/headers', 'next/server', 'n
|
|
|
22
22
|
const NODE_BUILTINS = new Set(builtinModules.map(name => name.replace(/^node:/u, '').split('/')[0]));
|
|
23
23
|
|
|
24
24
|
const isServerSpecifier = specifier => SERVER_MODULES.has(specifier) || specifier.startsWith('node:') || NODE_BUILTINS.has(specifier.split('/')[0]);
|
|
25
|
+
const isClientEntry = (ts, file) => {
|
|
26
|
+
const first = file.sourceFile.statements[0];
|
|
27
|
+
return Boolean(first) && ts.isExpressionStatement(first) && ts.isStringLiteral(first.expression) && first.expression.text === 'use client';
|
|
28
|
+
};
|
|
25
29
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
if (ts.isImportDeclaration(declaration)) {
|
|
35
|
-
const clause = declaration.importClause;
|
|
36
|
-
if (!clause) return true;
|
|
37
|
-
if (clause.isTypeOnly) return false;
|
|
38
|
-
if (!clause.name && clause.namedBindings && ts.isNamedImports(clause.namedBindings)) {
|
|
39
|
-
const elements = clause.namedBindings.elements;
|
|
40
|
-
return elements.length === 0 || !elements.every(element => element.isTypeOnly);
|
|
41
|
-
}
|
|
42
|
-
return true;
|
|
30
|
+
const reachesBundle = (ts, declaration) => {
|
|
31
|
+
if (ts.isImportDeclaration(declaration)) {
|
|
32
|
+
const clause = declaration.importClause;
|
|
33
|
+
if (!clause) return true;
|
|
34
|
+
if (clause.isTypeOnly) return false;
|
|
35
|
+
if (!clause.name && clause.namedBindings && ts.isNamedImports(clause.namedBindings)) {
|
|
36
|
+
const elements = clause.namedBindings.elements;
|
|
37
|
+
return elements.length === 0 || !elements.every(element => element.isTypeOnly);
|
|
43
38
|
}
|
|
44
|
-
return
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
};
|
|
56
|
-
visit
|
|
57
|
-
return found;
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
return !declaration.isTypeOnly;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const serverImports = (ts, file) => {
|
|
45
|
+
const found = [];
|
|
46
|
+
const visit = node => {
|
|
47
|
+
let specifier = null;
|
|
48
|
+
if ((ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) && node.moduleSpecifier && reachesBundle(ts, node)) specifier = node.moduleSpecifier;
|
|
49
|
+
else if (ts.isCallExpression(node) && (node.expression.kind === ts.SyntaxKind.ImportKeyword || (ts.isIdentifier(node.expression) && node.expression.text === 'require'))) specifier = node.arguments[0];
|
|
50
|
+
if (specifier && ts.isStringLiteralLike(specifier) && isServerSpecifier(specifier.text)) found.push({ specifier: specifier.text, node });
|
|
51
|
+
ts.forEachChild(node, visit);
|
|
58
52
|
};
|
|
53
|
+
visit(file.sourceFile);
|
|
54
|
+
return found;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const scanClientEntry = (entry, { ts, graph, runtime, violations }) => {
|
|
58
|
+
const seen = new Map([[entry.rel, { chain: [entry.rel], first: null }]]);
|
|
59
|
+
const queue = [entry.rel];
|
|
60
|
+
for (const rel of queue) {
|
|
61
|
+
if (isServerActionModule(ts, graph.files.get(rel).sourceFile)) continue;
|
|
62
|
+
const state = seen.get(rel);
|
|
63
|
+
for (const hit of runtime.serverOf(rel)) {
|
|
64
|
+
const at = state.first ?? sourceLocation(graph.files.get(rel).sourceFile, hit.node);
|
|
65
|
+
violations.push({
|
|
66
|
+
ruleId: RULE, path: entry.rel, line: at.line, column: at.column, clientEntry: entry.rel, importer: rel, specifier: hit.specifier, dependencyChain: state.chain,
|
|
67
|
+
message: `${entry.rel} is a client module ("use client") and reaches ${hit.specifier} through ${[...state.chain, hit.specifier].join(' -> ')}; a server-only surface never enters the client bundle. Read on the server and pass the value down, or move the import out of the client graph.`,
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
for (const edge of runtime.edges.get(rel) ?? []) {
|
|
71
|
+
if (seen.has(edge.to)) continue;
|
|
72
|
+
seen.set(edge.to, { chain: [...state.chain, edge.to], first: state.first ?? { line: edge.line, column: edge.column } });
|
|
73
|
+
queue.push(edge.to);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
};
|
|
59
77
|
|
|
78
|
+
export function checkClientReachesServer({ graph, context }) {
|
|
79
|
+
const ts = context.ts;
|
|
60
80
|
const runtime = new Map();
|
|
61
81
|
for (const edge of graph.edges) {
|
|
62
82
|
if (!edge.runtime) continue;
|
|
@@ -65,32 +85,13 @@ export function checkClientReachesServer({ graph, context }) {
|
|
|
65
85
|
}
|
|
66
86
|
const serverByFile = new Map();
|
|
67
87
|
const serverOf = rel => {
|
|
68
|
-
if (!serverByFile.has(rel)) serverByFile.set(rel, serverImports(graph.files.get(rel)));
|
|
88
|
+
if (!serverByFile.has(rel)) serverByFile.set(rel, serverImports(ts, graph.files.get(rel)));
|
|
69
89
|
return serverByFile.get(rel);
|
|
70
90
|
};
|
|
71
91
|
|
|
72
92
|
const violations = [];
|
|
73
|
-
const entries = [...graph.files.values()].filter(isClientEntry).sort((a, b) => a.rel.localeCompare(b.rel));
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
const queue = [entry.rel];
|
|
77
|
-
for (let head = 0; head < queue.length; head += 1) {
|
|
78
|
-
const rel = queue[head];
|
|
79
|
-
if (isServerActionModule(ts, graph.files.get(rel).sourceFile)) continue;
|
|
80
|
-
const state = seen.get(rel);
|
|
81
|
-
for (const hit of serverOf(rel)) {
|
|
82
|
-
const at = state.first ?? sourceLocation(graph.files.get(rel).sourceFile, hit.node);
|
|
83
|
-
violations.push({
|
|
84
|
-
ruleId: RULE, path: entry.rel, line: at.line, column: at.column, clientEntry: entry.rel, importer: rel, specifier: hit.specifier, dependencyChain: state.chain,
|
|
85
|
-
message: `${entry.rel} is a client module ("use client") and reaches ${hit.specifier} through ${[...state.chain, hit.specifier].join(' -> ')}; a server-only surface never enters the client bundle. Read on the server and pass the value down, or move the import out of the client graph.`,
|
|
86
|
-
});
|
|
87
|
-
}
|
|
88
|
-
for (const edge of runtime.get(rel) ?? []) {
|
|
89
|
-
if (seen.has(edge.to)) continue;
|
|
90
|
-
seen.set(edge.to, { chain: [...state.chain, edge.to], first: state.first ?? { line: edge.line, column: edge.column } });
|
|
91
|
-
queue.push(edge.to);
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
}
|
|
93
|
+
const entries = [...graph.files.values()].filter(file => isClientEntry(ts, file)).sort((a, b) => a.rel.localeCompare(b.rel));
|
|
94
|
+
const scanRuntime = { ts, graph, edges: runtime, serverOf, violations };
|
|
95
|
+
for (const entry of entries) scanClientEntry(entry, { ts, graph, runtime: scanRuntime, violations });
|
|
95
96
|
return { violations, coverage: { status: 'checked', clientEntries: entries.length } };
|
|
96
97
|
}
|
|
@@ -22,6 +22,8 @@
|
|
|
22
22
|
* canonical shape of section 5 of BE-CONVENTION. A door or main.ts that grows logic is refused by those enforcers, not
|
|
23
23
|
* excused here; every other file of the same slots (mappers, dto, app.module.ts) stays compared.
|
|
24
24
|
*/
|
|
25
|
+
import { byCodeUnit } from '../../lib/list.mjs';
|
|
26
|
+
|
|
25
27
|
export const CLONE_RULE_IDS = ['HFS_DUPLICATE_CODE'];
|
|
26
28
|
|
|
27
29
|
const MAX_VIOLATIONS = 200;
|
|
@@ -30,39 +32,54 @@ const APP_ENTRY = 'main.ts';
|
|
|
30
32
|
const ID = -1;
|
|
31
33
|
const LIT = -2;
|
|
32
34
|
|
|
35
|
+
const literalKindSet = (SyntaxKind) => new Set([SyntaxKind.StringLiteral, SyntaxKind.NumericLiteral, SyntaxKind.BigIntLiteral, SyntaxKind.RegularExpressionLiteral,
|
|
36
|
+
SyntaxKind.NoSubstitutionTemplateLiteral, SyntaxKind.TemplateHead, SyntaxKind.TemplateMiddle, SyntaxKind.TemplateTail, SyntaxKind.JsxText]);
|
|
37
|
+
|
|
38
|
+
function lineOfPosition(lineStarts, position) {
|
|
39
|
+
let low = 0;
|
|
40
|
+
let high = lineStarts.length - 1;
|
|
41
|
+
while (low < high) {
|
|
42
|
+
const mid = (low + high + 1) >> 1;
|
|
43
|
+
if (lineStarts[mid] <= position) low = mid; else high = mid - 1;
|
|
44
|
+
}
|
|
45
|
+
return low + 1;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** True for the nodes that carry no token: documentation comments and whitespace-only JSX text. */
|
|
49
|
+
function isTriviaNode(SyntaxKind, node) {
|
|
50
|
+
const kind = node.kind;
|
|
51
|
+
if (kind >= SyntaxKind.FirstJSDocNode && kind <= SyntaxKind.LastJSDocNode) return true;
|
|
52
|
+
return kind === SyntaxKind.JsxText && Boolean(node.containsOnlyTriviaWhiteSpaces);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function normalizedKindOf(SyntaxKind, literalKinds, kind) {
|
|
56
|
+
if (kind === SyntaxKind.Identifier || kind === SyntaxKind.PrivateIdentifier) return ID;
|
|
57
|
+
return literalKinds.has(kind) ? LIT : kind;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function visitToken(walk, node, inType) {
|
|
61
|
+
const { ts, sourceFile, kinds, lines, types, lineStarts, literalKinds } = walk;
|
|
62
|
+
const { SyntaxKind } = ts;
|
|
63
|
+
if (isTriviaNode(SyntaxKind, node)) return;
|
|
64
|
+
const kind = node.kind;
|
|
65
|
+
const typeScope = inType || kind === SyntaxKind.InterfaceDeclaration || kind === SyntaxKind.TypeAliasDeclaration;
|
|
66
|
+
kinds.push(normalizedKindOf(SyntaxKind, literalKinds, kind));
|
|
67
|
+
lines.push(lineOfPosition(lineStarts, node.getStart(sourceFile)));
|
|
68
|
+
types.push(typeScope ? 1 : 0);
|
|
69
|
+
ts.forEachChild(node, (child) => { visitToken(walk, child, typeScope); });
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Leading import and export-from statements repeat legitimately and are not tokenised. */
|
|
73
|
+
const isDroppedStatement = (SyntaxKind, statement) => statement.kind === SyntaxKind.ImportDeclaration || statement.kind === SyntaxKind.ImportEqualsDeclaration
|
|
74
|
+
|| (statement.kind === SyntaxKind.ExportDeclaration && statement.moduleSpecifier);
|
|
75
|
+
|
|
33
76
|
export function tokenize(ts, sourceFile) {
|
|
34
77
|
const { SyntaxKind } = ts;
|
|
35
|
-
const
|
|
36
|
-
SyntaxKind.NoSubstitutionTemplateLiteral, SyntaxKind.TemplateHead, SyntaxKind.TemplateMiddle, SyntaxKind.TemplateTail, SyntaxKind.JsxText]);
|
|
37
|
-
const kinds = [];
|
|
38
|
-
const lines = [];
|
|
39
|
-
const types = [];
|
|
40
|
-
const lineStarts = sourceFile.getLineStarts();
|
|
41
|
-
const lineOf = (position) => {
|
|
42
|
-
let low = 0;
|
|
43
|
-
let high = lineStarts.length - 1;
|
|
44
|
-
while (low < high) {
|
|
45
|
-
const mid = (low + high + 1) >> 1;
|
|
46
|
-
if (lineStarts[mid] <= position) low = mid; else high = mid - 1;
|
|
47
|
-
}
|
|
48
|
-
return low + 1;
|
|
49
|
-
};
|
|
50
|
-
const visit = (node, inType) => {
|
|
51
|
-
const kind = node.kind;
|
|
52
|
-
if (kind >= SyntaxKind.FirstJSDocNode && kind <= SyntaxKind.LastJSDocNode) return;
|
|
53
|
-
if (kind === SyntaxKind.JsxText && node.containsOnlyTriviaWhiteSpaces) return;
|
|
54
|
-
const typeScope = inType || kind === SyntaxKind.InterfaceDeclaration || kind === SyntaxKind.TypeAliasDeclaration;
|
|
55
|
-
kinds.push(kind === SyntaxKind.Identifier || kind === SyntaxKind.PrivateIdentifier ? ID : literalKinds.has(kind) ? LIT : kind);
|
|
56
|
-
lines.push(lineOf(node.getStart(sourceFile)));
|
|
57
|
-
types.push(typeScope ? 1 : 0);
|
|
58
|
-
ts.forEachChild(node, (child) => { visit(child, typeScope); });
|
|
59
|
-
};
|
|
78
|
+
const walk = { ts, sourceFile, kinds: [], lines: [], types: [], lineStarts: sourceFile.getLineStarts(), literalKinds: literalKindSet(SyntaxKind) };
|
|
60
79
|
for (const statement of sourceFile.statements) {
|
|
61
|
-
if (statement
|
|
62
|
-
|| (statement.kind === SyntaxKind.ExportDeclaration && statement.moduleSpecifier)) continue;
|
|
63
|
-
visit(statement, false);
|
|
80
|
+
if (!isDroppedStatement(SyntaxKind, statement)) visitToken(walk, statement, false);
|
|
64
81
|
}
|
|
65
|
-
return { kinds: Int32Array.from(kinds), lines: Int32Array.from(lines), types: Uint8Array.from(types) };
|
|
82
|
+
return { kinds: Int32Array.from(walk.kinds), lines: Int32Array.from(walk.lines), types: Uint8Array.from(walk.types) };
|
|
66
83
|
}
|
|
67
84
|
|
|
68
85
|
/** The app a file belongs to, by the slot binding of the manifest; null for a file of no app. */
|
|
@@ -86,17 +103,7 @@ function homeText(profile, sameOwner, crossApp) {
|
|
|
86
103
|
return "move the shared helper to src/modules/platform/primitives/ or into the owning capability's src/modules/domain/<capability>/ and import it from both";
|
|
87
104
|
}
|
|
88
105
|
|
|
89
|
-
|
|
90
|
-
const { lines: N, tokens: T } = graph.resolver.ruleParams().duplicateBlock;
|
|
91
|
-
const ts = context.ts ?? context.loaded.ts;
|
|
92
|
-
const entries = [];
|
|
93
|
-
for (const rel of [...graph.files.keys()].sort()) {
|
|
94
|
-
if (/\.d\.[cm]?tsx?$/.test(rel) || uniformByDesign(graph.resolver, rel)) continue;
|
|
95
|
-
const unit = graph.unit(rel);
|
|
96
|
-
if (!unit) continue;
|
|
97
|
-
entries.push({ rel, unit, ...tokenize(ts, graph.files.get(rel).sourceFile) });
|
|
98
|
-
}
|
|
99
|
-
// Rolling hash: two independent 32-bit polynomial prefix hashes, verified by token comparison on every match.
|
|
106
|
+
const cloneBuckets = (entries, N, T) => {
|
|
100
107
|
const B1 = 1000003;
|
|
101
108
|
const B2 = 92821;
|
|
102
109
|
let maxTokens = 0;
|
|
@@ -106,63 +113,109 @@ export function checkClones({ config, context, graph } = {}) {
|
|
|
106
113
|
pow1[0] = 1; pow2[0] = 1;
|
|
107
114
|
for (let i = 1; i <= maxTokens; i += 1) { pow1[i] = Math.imul(pow1[i - 1], B1); pow2[i] = Math.imul(pow2[i - 1], B2); }
|
|
108
115
|
const buckets = new Map();
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
};
|
|
139
|
-
|
|
116
|
+
const hashing = { pow1, pow2, B1, B2 };
|
|
117
|
+
entries.forEach((file, fileIndex) => addFileWindows(buckets, file, fileIndex, N, T, hashing));
|
|
118
|
+
return buckets;
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
function signedInt32(value) {
|
|
122
|
+
return Math.imul(value, 1);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const prefixHashes = (kinds, B1, B2) => {
|
|
126
|
+
const count = kinds.length;
|
|
127
|
+
const pre1 = new Int32Array(count + 1);
|
|
128
|
+
const pre2 = new Int32Array(count + 1);
|
|
129
|
+
for (let i = 0; i < count; i += 1) {
|
|
130
|
+
pre1[i + 1] = Math.imul(pre1[i], B1) + kinds[i] + 3;
|
|
131
|
+
pre2[i + 1] = Math.imul(pre2[i], B2) + kinds[i] + 7;
|
|
132
|
+
}
|
|
133
|
+
return { pre1, pre2 };
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
const windowKey = (pre, pow, start, end) => signedInt32(pre[end] - Math.imul(pre[start], pow[end - start]));
|
|
137
|
+
|
|
138
|
+
const addToBucket = (buckets, key, item) => {
|
|
139
|
+
const list = buckets.get(key);
|
|
140
|
+
if (list) list.push(item); else buckets.set(key, [item]);
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
const addFileWindows = (buckets, file, fileIndex, N, T, hashing) => {
|
|
144
|
+
const { pow1, pow2, B1, B2 } = hashing;
|
|
145
|
+
const { kinds, lines } = file;
|
|
146
|
+
const count = kinds.length;
|
|
147
|
+
const { pre1, pre2 } = prefixHashes(kinds, B1, B2);
|
|
148
|
+
let end = 0;
|
|
149
|
+
for (let start = 0; start < count; start += 1) {
|
|
150
|
+
if (start > 0 && lines[start] === lines[start - 1]) continue;
|
|
151
|
+
const limit = lines[start] + N;
|
|
152
|
+
if (end < start) end = start;
|
|
153
|
+
while (end < count && lines[end] < limit) end += 1;
|
|
154
|
+
if (end === start || lines[end - 1] - lines[start] + 1 < N) continue;
|
|
155
|
+
const length = end - start;
|
|
156
|
+
if (length < T) continue;
|
|
157
|
+
const key = `${windowKey(pre1, pow1, start, end)}:${windowKey(pre2, pow2, start, end)}:${length}`;
|
|
158
|
+
addToBucket(buckets, key, [fileIndex, start, end]);
|
|
159
|
+
}
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
const sameTokens = (entries, a, b) => {
|
|
163
|
+
const ka = entries[a[0]].kinds;
|
|
164
|
+
const kb = entries[b[0]].kinds;
|
|
165
|
+
for (let i = 0; i < a[2] - a[1]; i += 1) if (ka[a[1] + i] !== kb[b[1] + i]) return false;
|
|
166
|
+
return true;
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
/** The first other occurrence is the partner; overlapping windows of one file are one occurrence, not a copy. */
|
|
170
|
+
const partnerOf = (list, item) => list.find((other) => other !== item && (other[0] !== item[0] || other[1] >= item[2] || item[1] >= other[2]));
|
|
171
|
+
|
|
172
|
+
const orderedPair = (entries, item, partner) => {
|
|
173
|
+
const sameFile = entries[item[0]].rel === entries[partner[0]].rel;
|
|
174
|
+
const itemFirst = sameFile ? item[1] < partner[1] : entries[item[0]].rel < entries[partner[0]].rel;
|
|
175
|
+
return itemFirst ? [item, partner] : [partner, item];
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
const addHit = (hits, a, b) => {
|
|
179
|
+
const key = `${a[0]}|${b[0]}|${b[1] - a[1]}`;
|
|
180
|
+
const group = hits.get(key);
|
|
181
|
+
if (group) group.push([a[1], a[2]]); else hits.set(key, [[a[1], a[2]]]);
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
const cloneHits = (entries, buckets) => {
|
|
140
185
|
const hits = new Map();
|
|
141
186
|
for (const list of buckets.values()) {
|
|
142
187
|
if (list.length < 2) continue;
|
|
143
188
|
for (const item of list) {
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
const [a, b] = order ? [item, partner] : [partner, item];
|
|
149
|
-
const key = `${a[0]}|${b[0]}|${b[1] - a[1]}`;
|
|
150
|
-
const group = hits.get(key);
|
|
151
|
-
if (group) group.push([a[1], a[2]]); else hits.set(key, [[a[1], a[2]]]);
|
|
189
|
+
const partner = partnerOf(list, item);
|
|
190
|
+
if (!partner || !sameTokens(entries, item, partner)) continue;
|
|
191
|
+
const [a, b] = orderedPair(entries, item, partner);
|
|
192
|
+
addHit(hits, a, b);
|
|
152
193
|
}
|
|
153
194
|
}
|
|
195
|
+
return hits;
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
const blockShape = (file, start, end) => {
|
|
199
|
+
const nonType = new Set();
|
|
200
|
+
const shapes = new Map();
|
|
201
|
+
for (let i = start; i < end; i += 1) {
|
|
202
|
+
if (!file.types[i]) nonType.add(file.lines[i]);
|
|
203
|
+
shapes.set(file.lines[i], signedInt32((shapes.get(file.lines[i]) ?? 7) * 31 + file.kinds[i] + 5));
|
|
204
|
+
}
|
|
205
|
+
return { nonType: nonType.size, distinct: new Set(shapes.values()).size };
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
const appendCloneBlock = (blocks, fileA, fileB, start, end, diag, N) => {
|
|
209
|
+
const shape = blockShape(fileA, start, end);
|
|
210
|
+
if (shape.nonType < Math.ceil(N / 2) || shape.distinct < Math.ceil(N / 3)) return;
|
|
211
|
+
const lineA = fileA.lines[start];
|
|
212
|
+
const endA = fileA.lines[end - 1];
|
|
213
|
+
blocks.push({ a: fileA, b: fileB, line: lineA, endLine: endA, lines: endA - lineA + 1,
|
|
214
|
+
twin: { path: fileB.rel, line: fileB.lines[start + diag], endLine: fileB.lines[end - 1 + diag] } });
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
const cloneBlocks = (entries, hits, N) => {
|
|
154
218
|
const blocks = [];
|
|
155
|
-
// Shape of a block: the lines outside type declarations and the number of distinct line shapes (a field list of
|
|
156
|
-
// one decorated DTO shape repeated N times is boilerplate, not a copied helper).
|
|
157
|
-
const blockShape = (file, start, end) => {
|
|
158
|
-
const nonType = new Set();
|
|
159
|
-
const shapes = new Map();
|
|
160
|
-
for (let i = start; i < end; i += 1) {
|
|
161
|
-
if (!file.types[i]) nonType.add(file.lines[i]);
|
|
162
|
-
shapes.set(file.lines[i], ((shapes.get(file.lines[i]) ?? 7) * 31 + file.kinds[i] + 5) | 0);
|
|
163
|
-
}
|
|
164
|
-
return { nonType: nonType.size, distinct: new Set(shapes.values()).size };
|
|
165
|
-
};
|
|
166
219
|
for (const [key, group] of hits) {
|
|
167
220
|
const [ai, bi, diagText] = key.split('|');
|
|
168
221
|
const fileA = entries[Number(ai)];
|
|
@@ -170,31 +223,51 @@ export function checkClones({ config, context, graph } = {}) {
|
|
|
170
223
|
const diag = Number(diagText);
|
|
171
224
|
group.sort((x, y) => x[0] - y[0]);
|
|
172
225
|
let [start, end] = group[0];
|
|
173
|
-
const flush = () => {
|
|
174
|
-
const shape = blockShape(fileA, start, end);
|
|
175
|
-
if (shape.nonType < Math.ceil(N / 2) || shape.distinct < Math.ceil(N / 3)) return;
|
|
176
|
-
const lineA = fileA.lines[start];
|
|
177
|
-
const endA = fileA.lines[end - 1];
|
|
178
|
-
blocks.push({ a: fileA, b: fileB, line: lineA, endLine: endA, lines: endA - lineA + 1,
|
|
179
|
-
twin: { path: fileB.rel, line: fileB.lines[start + diag], endLine: fileB.lines[end - 1 + diag] } });
|
|
180
|
-
};
|
|
181
226
|
for (const [nextStart, nextEnd] of group.slice(1)) {
|
|
182
227
|
if (nextStart <= end) { end = Math.max(end, nextEnd); continue; }
|
|
183
|
-
|
|
228
|
+
appendCloneBlock(blocks, fileA, fileB, start, end, diag, N);
|
|
184
229
|
start = nextStart; end = nextEnd;
|
|
185
230
|
}
|
|
186
|
-
|
|
231
|
+
appendCloneBlock(blocks, fileA, fileB, start, end, diag, N);
|
|
187
232
|
}
|
|
233
|
+
return blocks;
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
const differentApps = (resolver, a, b) => {
|
|
237
|
+
const left = appOfFile(resolver, a);
|
|
238
|
+
const right = appOfFile(resolver, b);
|
|
239
|
+
return left !== null && right !== null && left !== right;
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
const cloneViolations = (blocks, resolver, profile, N, T) => {
|
|
188
243
|
blocks.sort((x, y) => x.a.rel.localeCompare(y.a.rel) || x.line - y.line || x.b.rel.localeCompare(y.b.rel));
|
|
189
|
-
|
|
190
|
-
const differentApps = (a, b) => { const left = appOfFile(graph.resolver, a); const right = appOfFile(graph.resolver, b); return left !== null && right !== null && left !== right; };
|
|
191
|
-
const violations = blocks.slice(0, MAX_VIOLATIONS).map((block) => ({
|
|
244
|
+
return blocks.slice(0, MAX_VIOLATIONS).map((block) => ({
|
|
192
245
|
ruleId: 'HFS_DUPLICATE_CODE', path: block.a.rel, line: block.line, endLine: block.endLine, twin: block.twin, lines: block.lines,
|
|
193
|
-
message: `${block.a.rel}:${block.line}-${block.endLine} duplicates ${block.twin.path}:${block.twin.line}-${block.twin.endLine} (${block.lines} lines, threshold ${N} lines / ${T} tokens, identifiers and literals ignored); ${homeText(profile, block.a.unit === block.b.unit, differentApps(block.a.rel, block.b.rel))}.`,
|
|
246
|
+
message: `${block.a.rel}:${block.line}-${block.endLine} duplicates ${block.twin.path}:${block.twin.line}-${block.twin.endLine} (${block.lines} lines, threshold ${N} lines / ${T} tokens, identifiers and literals ignored); ${homeText(profile, block.a.unit === block.b.unit, differentApps(resolver, block.a.rel, block.b.rel))}.`,
|
|
194
247
|
}));
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
const cloneCoverage = (entries, blocks, N, T) => {
|
|
195
251
|
const owners = new Set();
|
|
196
252
|
let duplicatedLines = 0;
|
|
197
253
|
for (const block of blocks) { owners.add(block.a.unit); owners.add(block.b.unit); duplicatedLines += block.lines; }
|
|
198
|
-
return {
|
|
199
|
-
...(blocks.length > MAX_VIOLATIONS ? { truncated: true } : {}) }
|
|
254
|
+
return { status: 'checked', minLines: N, minTokens: T, files: entries.length, cloneBlocks: blocks.length, ownersInvolved: owners.size, duplicatedLines,
|
|
255
|
+
...(blocks.length > MAX_VIOLATIONS ? { truncated: true } : {}) };
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
export function checkClones({ config, context, graph } = {}) {
|
|
259
|
+
const { lines: N, tokens: T } = graph.resolver.ruleParams().duplicateBlock;
|
|
260
|
+
const ts = context.ts ?? context.loaded.ts;
|
|
261
|
+
const entries = [];
|
|
262
|
+
for (const rel of [...graph.files.keys()].sort(byCodeUnit)) {
|
|
263
|
+
if (/\.d\.[cm]?tsx?$/.test(rel) || uniformByDesign(graph.resolver, rel)) continue;
|
|
264
|
+
const unit = graph.unit(rel);
|
|
265
|
+
if (!unit) continue;
|
|
266
|
+
entries.push({ rel, unit, ...tokenize(ts, graph.files.get(rel).sourceFile) });
|
|
267
|
+
}
|
|
268
|
+
const buckets = cloneBuckets(entries, N, T);
|
|
269
|
+
const blocks = cloneBlocks(entries, cloneHits(entries, buckets), N);
|
|
270
|
+
const profile = graph.profile;
|
|
271
|
+
const violations = cloneViolations(blocks, graph.resolver, profile, N, T);
|
|
272
|
+
return { violations, coverage: cloneCoverage(entries, blocks, N, T) };
|
|
200
273
|
}
|