@backthread/extractor 0.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/LICENSE +21 -0
- package/README.md +83 -0
- package/dist/cluster/domain-grouping.d.ts +113 -0
- package/dist/cluster/domain-grouping.d.ts.map +1 -0
- package/dist/cluster/domain-grouping.js +277 -0
- package/dist/cluster/domain-grouping.js.map +1 -0
- package/dist/cluster/grouping-gate.d.ts +54 -0
- package/dist/cluster/grouping-gate.d.ts.map +1 -0
- package/dist/cluster/grouping-gate.js +0 -0
- package/dist/cluster/grouping-gate.js.map +1 -0
- package/dist/cluster/louvain.d.ts +88 -0
- package/dist/cluster/louvain.d.ts.map +1 -0
- package/dist/cluster/louvain.js +674 -0
- package/dist/cluster/louvain.js.map +1 -0
- package/dist/cluster/overrides.d.ts +19 -0
- package/dist/cluster/overrides.d.ts.map +1 -0
- package/dist/cluster/overrides.js +32 -0
- package/dist/cluster/overrides.js.map +1 -0
- package/dist/cluster/subsystem.d.ts +45 -0
- package/dist/cluster/subsystem.d.ts.map +1 -0
- package/dist/cluster/subsystem.js +299 -0
- package/dist/cluster/subsystem.js.map +1 -0
- package/dist/cluster/workspaces.d.ts +64 -0
- package/dist/cluster/workspaces.d.ts.map +1 -0
- package/dist/cluster/workspaces.js +739 -0
- package/dist/cluster/workspaces.js.map +1 -0
- package/dist/extract.d.ts +52 -0
- package/dist/extract.d.ts.map +1 -0
- package/dist/extract.js +48 -0
- package/dist/extract.js.map +1 -0
- package/dist/framework/celery/celery.d.ts +17 -0
- package/dist/framework/celery/celery.d.ts.map +1 -0
- package/dist/framework/celery/celery.js +524 -0
- package/dist/framework/celery/celery.js.map +1 -0
- package/dist/framework/contribute-step.d.ts +38 -0
- package/dist/framework/contribute-step.d.ts.map +1 -0
- package/dist/framework/contribute-step.js +403 -0
- package/dist/framework/contribute-step.js.map +1 -0
- package/dist/framework/detect-step.d.ts +8 -0
- package/dist/framework/detect-step.d.ts.map +1 -0
- package/dist/framework/detect-step.js +54 -0
- package/dist/framework/detect-step.js.map +1 -0
- package/dist/framework/detect-util.d.ts +24 -0
- package/dist/framework/detect-util.d.ts.map +1 -0
- package/dist/framework/detect-util.js +59 -0
- package/dist/framework/detect-util.js.map +1 -0
- package/dist/framework/django/django.d.ts +22 -0
- package/dist/framework/django/django.d.ts.map +1 -0
- package/dist/framework/django/django.js +940 -0
- package/dist/framework/django/django.js.map +1 -0
- package/dist/framework/fastapi/fastapi.d.ts +27 -0
- package/dist/framework/fastapi/fastapi.d.ts.map +1 -0
- package/dist/framework/fastapi/fastapi.js +477 -0
- package/dist/framework/fastapi/fastapi.js.map +1 -0
- package/dist/framework/faststream/faststream.d.ts +19 -0
- package/dist/framework/faststream/faststream.d.ts.map +1 -0
- package/dist/framework/faststream/faststream.js +765 -0
- package/dist/framework/faststream/faststream.js.map +1 -0
- package/dist/framework/flask/flask.d.ts +17 -0
- package/dist/framework/flask/flask.d.ts.map +1 -0
- package/dist/framework/flask/flask.js +507 -0
- package/dist/framework/flask/flask.js.map +1 -0
- package/dist/framework/graphql/graphql.d.ts +17 -0
- package/dist/framework/graphql/graphql.d.ts.map +1 -0
- package/dist/framework/graphql/graphql.js +708 -0
- package/dist/framework/graphql/graphql.js.map +1 -0
- package/dist/framework/grpc/grpc.d.ts +22 -0
- package/dist/framework/grpc/grpc.d.ts.map +1 -0
- package/dist/framework/grpc/grpc.js +618 -0
- package/dist/framework/grpc/grpc.js.map +1 -0
- package/dist/framework/litestar/litestar.d.ts +17 -0
- package/dist/framework/litestar/litestar.d.ts.map +1 -0
- package/dist/framework/litestar/litestar.js +536 -0
- package/dist/framework/litestar/litestar.js.map +1 -0
- package/dist/framework/nest/nest.d.ts +19 -0
- package/dist/framework/nest/nest.d.ts.map +1 -0
- package/dist/framework/nest/nest.js +613 -0
- package/dist/framework/nest/nest.js.map +1 -0
- package/dist/framework/next/next.d.ts +22 -0
- package/dist/framework/next/next.d.ts.map +1 -0
- package/dist/framework/next/next.js +449 -0
- package/dist/framework/next/next.js.map +1 -0
- package/dist/framework/node/node.d.ts +24 -0
- package/dist/framework/node/node.d.ts.map +1 -0
- package/dist/framework/node/node.js +1009 -0
- package/dist/framework/node/node.js.map +1 -0
- package/dist/framework/orchestrator/orchestrator.d.ts +34 -0
- package/dist/framework/orchestrator/orchestrator.d.ts.map +1 -0
- package/dist/framework/orchestrator/orchestrator.js +691 -0
- package/dist/framework/orchestrator/orchestrator.js.map +1 -0
- package/dist/framework/orm/orm.d.ts +61 -0
- package/dist/framework/orm/orm.d.ts.map +1 -0
- package/dist/framework/orm/orm.js +620 -0
- package/dist/framework/orm/orm.js.map +1 -0
- package/dist/framework/python/analyze.d.ts +38 -0
- package/dist/framework/python/analyze.d.ts.map +1 -0
- package/dist/framework/python/analyze.js +98 -0
- package/dist/framework/python/analyze.js.map +1 -0
- package/dist/framework/python/py-ast.d.ts +130 -0
- package/dist/framework/python/py-ast.d.ts.map +1 -0
- package/dist/framework/python/py-ast.js +344 -0
- package/dist/framework/python/py-ast.js.map +1 -0
- package/dist/framework/python-orm/python-orm.d.ts +19 -0
- package/dist/framework/python-orm/python-orm.d.ts.map +1 -0
- package/dist/framework/python-orm/python-orm.js +516 -0
- package/dist/framework/python-orm/python-orm.js.map +1 -0
- package/dist/framework/react-native/react-native.d.ts +35 -0
- package/dist/framework/react-native/react-native.d.ts.map +1 -0
- package/dist/framework/react-native/react-native.js +1181 -0
- package/dist/framework/react-native/react-native.js.map +1 -0
- package/dist/framework/register.d.ts +2 -0
- package/dist/framework/register.d.ts.map +1 -0
- package/dist/framework/register.js +59 -0
- package/dist/framework/register.js.map +1 -0
- package/dist/framework/registry.d.ts +31 -0
- package/dist/framework/registry.d.ts.map +1 -0
- package/dist/framework/registry.js +70 -0
- package/dist/framework/registry.js.map +1 -0
- package/dist/framework/routing/expo-router.d.ts +31 -0
- package/dist/framework/routing/expo-router.d.ts.map +1 -0
- package/dist/framework/routing/expo-router.js +356 -0
- package/dist/framework/routing/expo-router.js.map +1 -0
- package/dist/framework/routing/index.d.ts +4 -0
- package/dist/framework/routing/index.d.ts.map +1 -0
- package/dist/framework/routing/index.js +16 -0
- package/dist/framework/routing/index.js.map +1 -0
- package/dist/framework/routing/next-router.d.ts +45 -0
- package/dist/framework/routing/next-router.d.ts.map +1 -0
- package/dist/framework/routing/next-router.js +378 -0
- package/dist/framework/routing/next-router.js.map +1 -0
- package/dist/framework/routing/route-tree.d.ts +73 -0
- package/dist/framework/routing/route-tree.d.ts.map +1 -0
- package/dist/framework/routing/route-tree.js +120 -0
- package/dist/framework/routing/route-tree.js.map +1 -0
- package/dist/framework/types.d.ts +230 -0
- package/dist/framework/types.d.ts.map +1 -0
- package/dist/framework/types.js +25 -0
- package/dist/framework/types.js.map +1 -0
- package/dist/graph/cross-language.d.ts +11 -0
- package/dist/graph/cross-language.d.ts.map +1 -0
- package/dist/graph/cross-language.js +123 -0
- package/dist/graph/cross-language.js.map +1 -0
- package/dist/graph/extract.d.ts +13 -0
- package/dist/graph/extract.d.ts.map +1 -0
- package/dist/graph/extract.js +79 -0
- package/dist/graph/extract.js.map +1 -0
- package/dist/graph/file-graph.d.ts +186 -0
- package/dist/graph/file-graph.d.ts.map +1 -0
- package/dist/graph/file-graph.js +427 -0
- package/dist/graph/file-graph.js.map +1 -0
- package/dist/graph/http-callsites.d.ts +14 -0
- package/dist/graph/http-callsites.d.ts.map +1 -0
- package/dist/graph/http-callsites.js +107 -0
- package/dist/graph/http-callsites.js.map +1 -0
- package/dist/graph/incremental.d.ts +122 -0
- package/dist/graph/incremental.d.ts.map +1 -0
- package/dist/graph/incremental.js +448 -0
- package/dist/graph/incremental.js.map +1 -0
- package/dist/graph/language.d.ts +31 -0
- package/dist/graph/language.d.ts.map +1 -0
- package/dist/graph/language.js +146 -0
- package/dist/graph/language.js.map +1 -0
- package/dist/graph/noise-filter.d.ts +46 -0
- package/dist/graph/noise-filter.d.ts.map +1 -0
- package/dist/graph/noise-filter.js +199 -0
- package/dist/graph/noise-filter.js.map +1 -0
- package/dist/graph/python-adapter.d.ts +36 -0
- package/dist/graph/python-adapter.d.ts.map +1 -0
- package/dist/graph/python-adapter.js +402 -0
- package/dist/graph/python-adapter.js.map +1 -0
- package/dist/graph/python-manifest.d.ts +22 -0
- package/dist/graph/python-manifest.d.ts.map +1 -0
- package/dist/graph/python-manifest.js +137 -0
- package/dist/graph/python-manifest.js.map +1 -0
- package/dist/graph/python-stdlib.d.ts +2 -0
- package/dist/graph/python-stdlib.d.ts.map +1 -0
- package/dist/graph/python-stdlib.js +53 -0
- package/dist/graph/python-stdlib.js.map +1 -0
- package/dist/graph/ts-morph-adapter.d.ts +65 -0
- package/dist/graph/ts-morph-adapter.d.ts.map +1 -0
- package/dist/graph/ts-morph-adapter.js +265 -0
- package/dist/graph/ts-morph-adapter.js.map +1 -0
- package/dist/graph/types.d.ts +37 -0
- package/dist/graph/types.d.ts.map +1 -0
- package/dist/graph/types.js +50 -0
- package/dist/graph/types.js.map +1 -0
- package/dist/index.d.ts +45 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +51 -0
- package/dist/index.js.map +1 -0
- package/dist/infra/aws/aws-parse.d.ts +39 -0
- package/dist/infra/aws/aws-parse.d.ts.map +1 -0
- package/dist/infra/aws/aws-parse.js +149 -0
- package/dist/infra/aws/aws-parse.js.map +1 -0
- package/dist/infra/aws/aws.d.ts +6 -0
- package/dist/infra/aws/aws.d.ts.map +1 -0
- package/dist/infra/aws/aws.js +425 -0
- package/dist/infra/aws/aws.js.map +1 -0
- package/dist/infra/azure/azure-parse.d.ts +73 -0
- package/dist/infra/azure/azure-parse.d.ts.map +1 -0
- package/dist/infra/azure/azure-parse.js +178 -0
- package/dist/infra/azure/azure-parse.js.map +1 -0
- package/dist/infra/azure/azure.d.ts +12 -0
- package/dist/infra/azure/azure.d.ts.map +1 -0
- package/dist/infra/azure/azure.js +673 -0
- package/dist/infra/azure/azure.js.map +1 -0
- package/dist/infra/cloudflare/cloudflare.d.ts +50 -0
- package/dist/infra/cloudflare/cloudflare.d.ts.map +1 -0
- package/dist/infra/cloudflare/cloudflare.js +382 -0
- package/dist/infra/cloudflare/cloudflare.js.map +1 -0
- package/dist/infra/cloudflare/wrangler-parse.d.ts +6 -0
- package/dist/infra/cloudflare/wrangler-parse.d.ts.map +1 -0
- package/dist/infra/cloudflare/wrangler-parse.js +278 -0
- package/dist/infra/cloudflare/wrangler-parse.js.map +1 -0
- package/dist/infra/compose/compose-parse.d.ts +24 -0
- package/dist/infra/compose/compose-parse.d.ts.map +1 -0
- package/dist/infra/compose/compose-parse.js +77 -0
- package/dist/infra/compose/compose-parse.js.map +1 -0
- package/dist/infra/compose/compose.d.ts +12 -0
- package/dist/infra/compose/compose.d.ts.map +1 -0
- package/dist/infra/compose/compose.js +201 -0
- package/dist/infra/compose/compose.js.map +1 -0
- package/dist/infra/convex/convex-parse.d.ts +28 -0
- package/dist/infra/convex/convex-parse.d.ts.map +1 -0
- package/dist/infra/convex/convex-parse.js +74 -0
- package/dist/infra/convex/convex-parse.js.map +1 -0
- package/dist/infra/convex/convex.d.ts +18 -0
- package/dist/infra/convex/convex.d.ts.map +1 -0
- package/dist/infra/convex/convex.js +199 -0
- package/dist/infra/convex/convex.js.map +1 -0
- package/dist/infra/digitalocean/digitalocean.d.ts +33 -0
- package/dist/infra/digitalocean/digitalocean.d.ts.map +1 -0
- package/dist/infra/digitalocean/digitalocean.js +204 -0
- package/dist/infra/digitalocean/digitalocean.js.map +1 -0
- package/dist/infra/firebase/firebase.d.ts +43 -0
- package/dist/infra/firebase/firebase.d.ts.map +1 -0
- package/dist/infra/firebase/firebase.js +271 -0
- package/dist/infra/firebase/firebase.js.map +1 -0
- package/dist/infra/fly/fly-parse.d.ts +52 -0
- package/dist/infra/fly/fly-parse.d.ts.map +1 -0
- package/dist/infra/fly/fly-parse.js +114 -0
- package/dist/infra/fly/fly-parse.js.map +1 -0
- package/dist/infra/fly/fly.d.ts +15 -0
- package/dist/infra/fly/fly.d.ts.map +1 -0
- package/dist/infra/fly/fly.js +265 -0
- package/dist/infra/fly/fly.js.map +1 -0
- package/dist/infra/gcp/gcp-parse.d.ts +24 -0
- package/dist/infra/gcp/gcp-parse.d.ts.map +1 -0
- package/dist/infra/gcp/gcp-parse.js +123 -0
- package/dist/infra/gcp/gcp-parse.js.map +1 -0
- package/dist/infra/gcp/gcp.d.ts +10 -0
- package/dist/infra/gcp/gcp.d.ts.map +1 -0
- package/dist/infra/gcp/gcp.js +502 -0
- package/dist/infra/gcp/gcp.js.map +1 -0
- package/dist/infra/heroku/heroku-parse.d.ts +33 -0
- package/dist/infra/heroku/heroku-parse.d.ts.map +1 -0
- package/dist/infra/heroku/heroku-parse.js +63 -0
- package/dist/infra/heroku/heroku-parse.js.map +1 -0
- package/dist/infra/heroku/heroku.d.ts +24 -0
- package/dist/infra/heroku/heroku.d.ts.map +1 -0
- package/dist/infra/heroku/heroku.js +253 -0
- package/dist/infra/heroku/heroku.js.map +1 -0
- package/dist/infra/image-resolve.d.ts +56 -0
- package/dist/infra/image-resolve.d.ts.map +1 -0
- package/dist/infra/image-resolve.js +229 -0
- package/dist/infra/image-resolve.js.map +1 -0
- package/dist/infra/infra-step.d.ts +39 -0
- package/dist/infra/infra-step.d.ts.map +1 -0
- package/dist/infra/infra-step.js +57 -0
- package/dist/infra/infra-step.js.map +1 -0
- package/dist/infra/k8s/index.d.ts +78 -0
- package/dist/infra/k8s/index.d.ts.map +1 -0
- package/dist/infra/k8s/index.js +251 -0
- package/dist/infra/k8s/index.js.map +1 -0
- package/dist/infra/kamal/kamal-parse.d.ts +23 -0
- package/dist/infra/kamal/kamal-parse.d.ts.map +1 -0
- package/dist/infra/kamal/kamal-parse.js +66 -0
- package/dist/infra/kamal/kamal-parse.js.map +1 -0
- package/dist/infra/kamal/kamal.d.ts +17 -0
- package/dist/infra/kamal/kamal.d.ts.map +1 -0
- package/dist/infra/kamal/kamal.js +162 -0
- package/dist/infra/kamal/kamal.js.map +1 -0
- package/dist/infra/netlify/netlify.d.ts +33 -0
- package/dist/infra/netlify/netlify.d.ts.map +1 -0
- package/dist/infra/netlify/netlify.js +247 -0
- package/dist/infra/netlify/netlify.js.map +1 -0
- package/dist/infra/opentofu/opentofu.d.ts +3 -0
- package/dist/infra/opentofu/opentofu.d.ts.map +1 -0
- package/dist/infra/opentofu/opentofu.js +157 -0
- package/dist/infra/opentofu/opentofu.js.map +1 -0
- package/dist/infra/pulumi/pulumi-parse.d.ts +60 -0
- package/dist/infra/pulumi/pulumi-parse.d.ts.map +1 -0
- package/dist/infra/pulumi/pulumi-parse.js +155 -0
- package/dist/infra/pulumi/pulumi-parse.js.map +1 -0
- package/dist/infra/pulumi/pulumi.d.ts +23 -0
- package/dist/infra/pulumi/pulumi.d.ts.map +1 -0
- package/dist/infra/pulumi/pulumi.js +260 -0
- package/dist/infra/pulumi/pulumi.js.map +1 -0
- package/dist/infra/railway/railway-parse.d.ts +54 -0
- package/dist/infra/railway/railway-parse.d.ts.map +1 -0
- package/dist/infra/railway/railway-parse.js +200 -0
- package/dist/infra/railway/railway-parse.js.map +1 -0
- package/dist/infra/railway/railway.d.ts +19 -0
- package/dist/infra/railway/railway.d.ts.map +1 -0
- package/dist/infra/railway/railway.js +397 -0
- package/dist/infra/railway/railway.js.map +1 -0
- package/dist/infra/register.d.ts +2 -0
- package/dist/infra/register.d.ts.map +1 -0
- package/dist/infra/register.js +81 -0
- package/dist/infra/register.js.map +1 -0
- package/dist/infra/registry.d.ts +64 -0
- package/dist/infra/registry.d.ts.map +1 -0
- package/dist/infra/registry.js +205 -0
- package/dist/infra/registry.js.map +1 -0
- package/dist/infra/relevance.d.ts +16 -0
- package/dist/infra/relevance.d.ts.map +1 -0
- package/dist/infra/relevance.js +132 -0
- package/dist/infra/relevance.js.map +1 -0
- package/dist/infra/render/render-parse.d.ts +76 -0
- package/dist/infra/render/render-parse.d.ts.map +1 -0
- package/dist/infra/render/render-parse.js +155 -0
- package/dist/infra/render/render-parse.js.map +1 -0
- package/dist/infra/render/render.d.ts +11 -0
- package/dist/infra/render/render.d.ts.map +1 -0
- package/dist/infra/render/render.js +303 -0
- package/dist/infra/render/render.js.map +1 -0
- package/dist/infra/sst/sst-parse.d.ts +25 -0
- package/dist/infra/sst/sst-parse.d.ts.map +1 -0
- package/dist/infra/sst/sst-parse.js +45 -0
- package/dist/infra/sst/sst-parse.js.map +1 -0
- package/dist/infra/sst/sst.d.ts +19 -0
- package/dist/infra/sst/sst.d.ts.map +1 -0
- package/dist/infra/sst/sst.js +236 -0
- package/dist/infra/sst/sst.js.map +1 -0
- package/dist/infra/supabase/supabase-parse.d.ts +34 -0
- package/dist/infra/supabase/supabase-parse.d.ts.map +1 -0
- package/dist/infra/supabase/supabase-parse.js +113 -0
- package/dist/infra/supabase/supabase-parse.js.map +1 -0
- package/dist/infra/supabase/supabase.d.ts +15 -0
- package/dist/infra/supabase/supabase.d.ts.map +1 -0
- package/dist/infra/supabase/supabase.js +198 -0
- package/dist/infra/supabase/supabase.js.map +1 -0
- package/dist/infra/terraform/hcl-parse.d.ts +34 -0
- package/dist/infra/terraform/hcl-parse.d.ts.map +1 -0
- package/dist/infra/terraform/hcl-parse.js +203 -0
- package/dist/infra/terraform/hcl-parse.js.map +1 -0
- package/dist/infra/terraform/terraform.d.ts +14 -0
- package/dist/infra/terraform/terraform.d.ts.map +1 -0
- package/dist/infra/terraform/terraform.js +264 -0
- package/dist/infra/terraform/terraform.js.map +1 -0
- package/dist/infra/types.d.ts +151 -0
- package/dist/infra/types.d.ts.map +1 -0
- package/dist/infra/types.js +14 -0
- package/dist/infra/types.js.map +1 -0
- package/dist/infra/vercel/vercel-parse.d.ts +70 -0
- package/dist/infra/vercel/vercel-parse.d.ts.map +1 -0
- package/dist/infra/vercel/vercel-parse.js +194 -0
- package/dist/infra/vercel/vercel-parse.js.map +1 -0
- package/dist/infra/vercel/vercel.d.ts +30 -0
- package/dist/infra/vercel/vercel.d.ts.map +1 -0
- package/dist/infra/vercel/vercel.js +523 -0
- package/dist/infra/vercel/vercel.js.map +1 -0
- package/dist/infra/walk.d.ts +36 -0
- package/dist/infra/walk.d.ts.map +1 -0
- package/dist/infra/walk.js +85 -0
- package/dist/infra/walk.js.map +1 -0
- package/dist/safety.d.ts +13 -0
- package/dist/safety.d.ts.map +1 -0
- package/dist/safety.js +67 -0
- package/dist/safety.js.map +1 -0
- package/dist/types.d.ts +47 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +111 -0
- package/dist/types.js.map +1 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +35 -0
- package/dist/version.js.map +1 -0
- package/package.json +65 -0
|
@@ -0,0 +1,674 @@
|
|
|
1
|
+
// Module clustering + god-node detection (/326).
|
|
2
|
+
//
|
|
3
|
+
// Turns the file-level NormalizedGraph into the diagram's MODULES. Boundaries
|
|
4
|
+
// come from Louvain community detection on the import/call graph — the
|
|
5
|
+
// deterministic, content-derived source. Explicitly NOT folders (a bad graph
|
|
6
|
+
// for the messy-repo ICP) and NOT commit co-change (dies under coarse AI-agent
|
|
7
|
+
// commits). The LLM names clusters later; it never sets boundaries.
|
|
8
|
+
//
|
|
9
|
+
// Externals are NOT clustered — each external dependency is its own leaf module
|
|
10
|
+
// (kind 'external'); only internal files participate in community detection.
|
|
11
|
+
//
|
|
12
|
+
// Stage C (decides manifest-first, Louvain fallback): when a
|
|
13
|
+
// WorkspaceLayout with ≥2 packages is supplied, declared package boundaries
|
|
14
|
+
// become first-class — Louvain runs only WITHIN each package (small graphs,
|
|
15
|
+
// stable partitions, optionally warm-reused via PackagePartitionCache), the
|
|
16
|
+
// boundary BETWEEN packages is the manifest's word and never statistical, and
|
|
17
|
+
// cross-package edges aggregate to module edges at full confidence. A bare
|
|
18
|
+
// `import x from '@org/pkg-b'` that the install-free extractor classified as
|
|
19
|
+
// EXTERNAL is remapped back onto pkg-b's entry module when pkg-b is a
|
|
20
|
+
// workspace sibling. `stabilizeModuleIds` warm-starts module identity from
|
|
21
|
+
// the prior extraction so the changelog join key + the LLM set-diff
|
|
22
|
+
// (enrich/diff.ts) survive re-clusters — id stability directly cuts LLM spend.
|
|
23
|
+
// With no options (or a single-package layout) behavior is byte-identical to
|
|
24
|
+
// pre-Stage-C.
|
|
25
|
+
import Graph from 'graphology';
|
|
26
|
+
import louvain from 'graphology-communities-louvain';
|
|
27
|
+
import { externalIdFor } from '../graph/types.js';
|
|
28
|
+
import { compileMatchers, slugify } from './overrides.js';
|
|
29
|
+
// God-node detection ( / , fixes ). The old fixed-degree
|
|
30
|
+
// threshold fired on ~half the modules of a small dense repo (4/8 on backthread),
|
|
31
|
+
// which makes the signal meaningless. Replaced with a statistical-outlier rule
|
|
32
|
+
// that scales with graph size: a module is a god-node iff it is BOTH an
|
|
33
|
+
// internal-degree outlier (z-score > 1.5σ above the mean) AND a routing
|
|
34
|
+
// chokepoint (betweenness centrality in the top quartile). AND, not OR —
|
|
35
|
+
// precision over recall, because a false "scary to refactor" flag destroys
|
|
36
|
+
// trust in the signal. Below MIN_MODULES the statistics are noise, so we never
|
|
37
|
+
// flag.
|
|
38
|
+
const GOD_NODE_Z_THRESHOLD = 1.5;
|
|
39
|
+
const GOD_NODE_MIN_MODULES = 5;
|
|
40
|
+
// Seeded PRNG (mulberry32). Louvain defaults to Math.random for node ordering,
|
|
41
|
+
// so a fixed seed makes communities — and the module ids derived from them —
|
|
42
|
+
// stable across runs. Required for idempotent re-sync and P4 snapshot diffing.
|
|
43
|
+
function mulberry32(seed) {
|
|
44
|
+
return () => {
|
|
45
|
+
seed = (seed + 0x6d2b79f5) | 0;
|
|
46
|
+
let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
|
|
47
|
+
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
|
48
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
// Derive the dominant path segment of a community's files: the most common
|
|
52
|
+
// segment after an optional leading `src/` (single-segment paths fall back to
|
|
53
|
+
// the extension-less basename). Pure naming heuristic — the LLM relabels for
|
|
54
|
+
// display; the id built from this is the stable join key + override target.
|
|
55
|
+
function deriveSegment(fileIds) {
|
|
56
|
+
const counts = new Map();
|
|
57
|
+
for (const f of fileIds) {
|
|
58
|
+
const parts = f.replace(/^src\//, '').split('/');
|
|
59
|
+
const seg = parts.length > 1 ? parts[0] : (parts[0].replace(/\.[^.]+$/, '') || 'root');
|
|
60
|
+
counts.set(seg, (counts.get(seg) ?? 0) + 1);
|
|
61
|
+
}
|
|
62
|
+
let best = 'module';
|
|
63
|
+
let bestN = -1;
|
|
64
|
+
for (const [seg, n] of counts) {
|
|
65
|
+
if (n > bestN) {
|
|
66
|
+
best = seg;
|
|
67
|
+
bestN = n;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return best;
|
|
71
|
+
}
|
|
72
|
+
// Reserve a unique module id from a base: `-2`, `-3`, … suffixes on collision.
|
|
73
|
+
// The ONE dedup gate every id-producing path goes through (derived, slug-
|
|
74
|
+
// prefixed, stabilization reroutes), so ids are unique by construction.
|
|
75
|
+
function reserveId(base, used) {
|
|
76
|
+
let id = base;
|
|
77
|
+
let i = 2;
|
|
78
|
+
while (used.has(id))
|
|
79
|
+
id = `${base}-${i++}`;
|
|
80
|
+
used.add(id);
|
|
81
|
+
return id;
|
|
82
|
+
}
|
|
83
|
+
// Derive a provisional module id from a community's files (pre-Stage-C form,
|
|
84
|
+
// used for the whole-graph path + root-scope communities).
|
|
85
|
+
function deriveModuleId(fileIds, used) {
|
|
86
|
+
return reserveId(slugify(deriveSegment(fileIds)) || 'module', used);
|
|
87
|
+
}
|
|
88
|
+
export class PackagePartitionCache {
|
|
89
|
+
entries = new Map();
|
|
90
|
+
dirty = new Set();
|
|
91
|
+
/**
|
|
92
|
+
* Mark the packages owning these paths dirty — call with each checkpoint's
|
|
93
|
+
* diff paths BEFORE clustering that checkpoint. Conservative on purpose:
|
|
94
|
+
* any touched path (source or not) dirties its owning package; a non-source
|
|
95
|
+
* path costs at most one redundant re-cluster of a small subgraph.
|
|
96
|
+
*/
|
|
97
|
+
invalidatePaths(paths, layout) {
|
|
98
|
+
for (const p of paths)
|
|
99
|
+
this.dirty.add(layout.packageOf(p).slug);
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Drop everything — call on a full re-extract, a workspace-layout change
|
|
103
|
+
* (a diff touching isWorkspaceManifestPath), or an overrides change (slugs
|
|
104
|
+
* and forced membership both feed the stored partitions).
|
|
105
|
+
*/
|
|
106
|
+
invalidateAll() {
|
|
107
|
+
this.entries.clear();
|
|
108
|
+
this.dirty.clear();
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* The cluster-layer read: a stored partition is reusable ONLY when the
|
|
112
|
+
* package is clean AND its member set is unchanged (`memberKey` = sorted
|
|
113
|
+
* '\n'-join of member fileIds — the same key clusterGraph computes).
|
|
114
|
+
*/
|
|
115
|
+
lookup(slug, memberKey) {
|
|
116
|
+
if (this.dirty.has(slug))
|
|
117
|
+
return null;
|
|
118
|
+
const e = this.entries.get(slug);
|
|
119
|
+
return e !== undefined && e.memberKey === memberKey ? e.communities : null;
|
|
120
|
+
}
|
|
121
|
+
/** The cluster-layer write after a (re-)cluster; clears the dirty mark. */
|
|
122
|
+
store(slug, memberKey, communities) {
|
|
123
|
+
this.entries.set(slug, { memberKey, communities });
|
|
124
|
+
this.dirty.delete(slug);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
export function clusterGraph(graph, overrides, opts) {
|
|
128
|
+
const drop = compileMatchers(overrides?.drop ?? []);
|
|
129
|
+
const assign = (overrides?.assign ?? []).map((rule) => ({
|
|
130
|
+
match: compileMatchers([rule.pattern]),
|
|
131
|
+
moduleId: rule.moduleId,
|
|
132
|
+
}));
|
|
133
|
+
// Internal files minus dropped noise.
|
|
134
|
+
const files = graph.files.filter((f) => !drop(f.id));
|
|
135
|
+
const fileSet = new Set(files.map((f) => f.id));
|
|
136
|
+
const locById = new Map(files.map((f) => [f.id, f.loc]));
|
|
137
|
+
// `resolution` (override-tunable, default 1) trades coarse↔fine modules —
|
|
138
|
+
// raise it to split a too-coarse clustering (the backthread dogfood uses >1).
|
|
139
|
+
const resolution = overrides?.resolution ?? 1;
|
|
140
|
+
// `assign` overrides (forced membership) win over ANY community detection,
|
|
141
|
+
// on both the whole-graph and per-package paths. Forced files still sit in
|
|
142
|
+
// the Louvain graph as nodes (their edges shape the partition around them,
|
|
143
|
+
// exactly as pre-Stage-C) — only their module ASSIGNMENT is overridden.
|
|
144
|
+
const forced = new Map(); // fileId → overridden moduleId
|
|
145
|
+
for (const f of files) {
|
|
146
|
+
const override = assign.find((a) => a.match(f.id));
|
|
147
|
+
if (override)
|
|
148
|
+
forced.set(f.id, override.moduleId);
|
|
149
|
+
}
|
|
150
|
+
const groups = [];
|
|
151
|
+
const layout = opts?.layout;
|
|
152
|
+
const multiPackage = layout !== undefined && layout.packages.length > 1;
|
|
153
|
+
if (!multiPackage) {
|
|
154
|
+
// ── Whole-graph path (pre-Stage-C, byte-identical) ─────────────────────
|
|
155
|
+
// Build the undirected weighted graph for Louvain (internal↔internal edges).
|
|
156
|
+
const g = new Graph({ type: 'undirected' });
|
|
157
|
+
for (const f of files)
|
|
158
|
+
g.addNode(f.id);
|
|
159
|
+
for (const e of graph.edges) {
|
|
160
|
+
if (e.external)
|
|
161
|
+
continue;
|
|
162
|
+
if (!fileSet.has(e.from) || !fileSet.has(e.to))
|
|
163
|
+
continue;
|
|
164
|
+
if (g.hasEdge(e.from, e.to)) {
|
|
165
|
+
g.updateEdgeAttribute(e.from, e.to, 'weight', (w) => (w ?? 1) + e.weight);
|
|
166
|
+
}
|
|
167
|
+
else {
|
|
168
|
+
g.addEdge(e.from, e.to, { weight: e.weight });
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
// Community detection → fileId → community index. Seeded RNG for stable ids.
|
|
172
|
+
const communities = g.order > 0
|
|
173
|
+
? louvain(g, { getEdgeWeight: 'weight', resolution, rng: mulberry32(42) })
|
|
174
|
+
: {};
|
|
175
|
+
const byCommunity = new Map();
|
|
176
|
+
for (const f of files) {
|
|
177
|
+
if (forced.has(f.id))
|
|
178
|
+
continue;
|
|
179
|
+
const c = communities[f.id] ?? -1;
|
|
180
|
+
const arr = byCommunity.get(c) ?? [];
|
|
181
|
+
arr.push(f.id);
|
|
182
|
+
byCommunity.set(c, arr);
|
|
183
|
+
}
|
|
184
|
+
for (const fileIds of byCommunity.values())
|
|
185
|
+
groups.push({ pkg: null, fileIds, sole: false });
|
|
186
|
+
}
|
|
187
|
+
else {
|
|
188
|
+
// ── Per-package path ( Stage C) ─────────────────────────────────
|
|
189
|
+
// Louvain runs on the subgraph induced by edges with BOTH endpoints in the
|
|
190
|
+
// same package. Cross-package edges are EXCLUDED from community detection
|
|
191
|
+
// — the boundary is declared by the manifest, not discovered statistically
|
|
192
|
+
// — but they still aggregate to module edges below, at full
|
|
193
|
+
// confidence like any other edge.
|
|
194
|
+
const ownerRoot = new Map(); // fileId → owning package root
|
|
195
|
+
const membersByRoot = new Map();
|
|
196
|
+
for (const f of files) {
|
|
197
|
+
const r = layout.packageOf(f.id).root;
|
|
198
|
+
ownerRoot.set(f.id, r);
|
|
199
|
+
const arr = membersByRoot.get(r) ?? [];
|
|
200
|
+
arr.push(f.id);
|
|
201
|
+
membersByRoot.set(r, arr);
|
|
202
|
+
}
|
|
203
|
+
const intraEdgesByRoot = new Map();
|
|
204
|
+
for (const e of graph.edges) {
|
|
205
|
+
if (e.external)
|
|
206
|
+
continue;
|
|
207
|
+
if (!fileSet.has(e.from) || !fileSet.has(e.to))
|
|
208
|
+
continue;
|
|
209
|
+
const r = ownerRoot.get(e.from);
|
|
210
|
+
if (r !== ownerRoot.get(e.to))
|
|
211
|
+
continue; // cross-package: not detection input
|
|
212
|
+
const arr = intraEdgesByRoot.get(r) ?? [];
|
|
213
|
+
arr.push(e);
|
|
214
|
+
intraEdgesByRoot.set(r, arr);
|
|
215
|
+
}
|
|
216
|
+
// Packages in root-sorted order — module materialization order (and the
|
|
217
|
+
// id-dedup order downstream of it) must not depend on layout insertion
|
|
218
|
+
// order. Determinism is load-bearing everywhere in this file.
|
|
219
|
+
const sortedPkgs = [...layout.packages].sort((a, b) => a.root < b.root ? -1 : a.root > b.root ? 1 : 0);
|
|
220
|
+
for (const pkg of sortedPkgs) {
|
|
221
|
+
const members = membersByRoot.get(pkg.root) ?? [];
|
|
222
|
+
if (members.length === 0)
|
|
223
|
+
continue;
|
|
224
|
+
// Warm reuse: clean package + identical member set ⇒ the stored
|
|
225
|
+
// partition is the partition (see PackagePartitionCache for why).
|
|
226
|
+
const memberKey = [...members].sort().join('\n');
|
|
227
|
+
let communities = opts?.partitionCache?.lookup(pkg.slug, memberKey) ?? null;
|
|
228
|
+
if (communities === null) {
|
|
229
|
+
const sub = new Graph({ type: 'undirected' });
|
|
230
|
+
for (const id of members)
|
|
231
|
+
sub.addNode(id);
|
|
232
|
+
for (const e of intraEdgesByRoot.get(pkg.root) ?? []) {
|
|
233
|
+
if (sub.hasEdge(e.from, e.to)) {
|
|
234
|
+
sub.updateEdgeAttribute(e.from, e.to, 'weight', (w) => (w ?? 1) + e.weight);
|
|
235
|
+
}
|
|
236
|
+
else {
|
|
237
|
+
sub.addEdge(e.from, e.to, { weight: e.weight });
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
// Same seeded RNG per package: a package's partition depends only on
|
|
241
|
+
// its own subgraph, never on sibling packages — that's the stability
|
|
242
|
+
// win over whole-graph Louvain. (Edgeless subgraphs are handled by the
|
|
243
|
+
// library: every node becomes its own community, in insertion order.)
|
|
244
|
+
communities = louvain(sub, { getEdgeWeight: 'weight', resolution, rng: mulberry32(42) });
|
|
245
|
+
opts?.partitionCache?.store(pkg.slug, memberKey, communities);
|
|
246
|
+
}
|
|
247
|
+
const byCommunity = new Map();
|
|
248
|
+
for (const id of members) {
|
|
249
|
+
if (forced.has(id))
|
|
250
|
+
continue;
|
|
251
|
+
const c = communities[id] ?? -1;
|
|
252
|
+
const arr = byCommunity.get(c) ?? [];
|
|
253
|
+
arr.push(id);
|
|
254
|
+
byCommunity.set(c, arr);
|
|
255
|
+
}
|
|
256
|
+
const sole = byCommunity.size === 1;
|
|
257
|
+
for (const fileIds of byCommunity.values())
|
|
258
|
+
groups.push({ pkg, fileIds, sole });
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
// Materialise internal modules.
|
|
262
|
+
const fileModuleMap = {};
|
|
263
|
+
const modules = [];
|
|
264
|
+
const usedIds = new Set();
|
|
265
|
+
// Forced-assignment modules first (so their ids are reserved).
|
|
266
|
+
const forcedGroups = new Map();
|
|
267
|
+
for (const [fileId, moduleId] of forced) {
|
|
268
|
+
const arr = forcedGroups.get(moduleId) ?? [];
|
|
269
|
+
arr.push(fileId);
|
|
270
|
+
forcedGroups.set(moduleId, arr);
|
|
271
|
+
}
|
|
272
|
+
for (const [moduleId, fileIds] of forcedGroups) {
|
|
273
|
+
usedIds.add(moduleId);
|
|
274
|
+
for (const f of fileIds)
|
|
275
|
+
fileModuleMap[f] = moduleId;
|
|
276
|
+
modules.push(makeInternalModule(moduleId, fileIds, locById));
|
|
277
|
+
}
|
|
278
|
+
// Community modules. Ids: root-scope communities keep the bare derived id
|
|
279
|
+
// (pre-Stage-C behavior); a non-root package that is ONE community gets the
|
|
280
|
+
// package slug itself (the package IS the module); a multi-community package
|
|
281
|
+
// gets `<slug>-<derived>` with the derivation on PACKAGE-RELATIVE paths (so
|
|
282
|
+
// `worker/container/src/foo.ts` derives from `src/foo.ts`, not `worker`).
|
|
283
|
+
for (const grp of groups) {
|
|
284
|
+
const { pkg, fileIds } = grp;
|
|
285
|
+
let id;
|
|
286
|
+
if (pkg === null || pkg.root === '') {
|
|
287
|
+
id = deriveModuleId(fileIds, usedIds);
|
|
288
|
+
}
|
|
289
|
+
else if (grp.sole) {
|
|
290
|
+
id = reserveId(pkg.slug, usedIds);
|
|
291
|
+
}
|
|
292
|
+
else {
|
|
293
|
+
const rel = fileIds.map((f) => f.startsWith(`${pkg.root}/`) ? f.slice(pkg.root.length + 1) : f);
|
|
294
|
+
const seg = slugify(deriveSegment(rel)) || 'module';
|
|
295
|
+
id = reserveId(`${pkg.slug}-${seg}`, usedIds);
|
|
296
|
+
}
|
|
297
|
+
for (const f of fileIds)
|
|
298
|
+
fileModuleMap[f] = id;
|
|
299
|
+
const mod = makeInternalModule(id, fileIds, locById);
|
|
300
|
+
if (pkg !== null && pkg.root !== '') {
|
|
301
|
+
mod.packageId = pkg.slug;
|
|
302
|
+
mod.packageName = pkg.name; // humanized for the subsystem box label
|
|
303
|
+
mod.packageRole = pkg.role; // app / lib / tooling
|
|
304
|
+
}
|
|
305
|
+
modules.push(mod);
|
|
306
|
+
}
|
|
307
|
+
// Module-id stabilization ( Stage C warm-start) — AFTER modules are
|
|
308
|
+
// materialized, BEFORE edge aggregation + god-node detection (both read the
|
|
309
|
+
// FINAL ids) and before the workspace remap below (entry-module resolution
|
|
310
|
+
// must see stabilized ids).
|
|
311
|
+
if (opts?.priorModules !== undefined && opts.priorModules.length > 0) {
|
|
312
|
+
stabilizeModuleIds(modules, fileModuleMap, opts.priorModules, {
|
|
313
|
+
forcedIds: new Set(forcedGroups.keys()),
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
// Workspace-name external remap ( Stage C — the cross-package edge
|
|
317
|
+
// win). The install-free extractor sees `import x from '@org/pkg-b'` as a
|
|
318
|
+
// bare specifier → an EXTERNAL node, even when pkg-b is a workspace sibling
|
|
319
|
+
// sitting right there in the repo. With a layout we know better: map each
|
|
320
|
+
// named non-root package's external id to its ENTRY module (first entry
|
|
321
|
+
// candidate present in the file→module map). Unresolvable entry → no remap,
|
|
322
|
+
// the external stays (fail-soft — never guess a target).
|
|
323
|
+
const workspaceEntryByExtId = new Map();
|
|
324
|
+
if (layout !== undefined) {
|
|
325
|
+
for (const pkg of layout.nameToPackage.values()) {
|
|
326
|
+
const extId = externalIdFor(pkg.name).id;
|
|
327
|
+
const entry = pkg.entryFileIds.find((f) => fileModuleMap[f] !== undefined);
|
|
328
|
+
if (entry !== undefined)
|
|
329
|
+
workspaceEntryByExtId.set(extId, fileModuleMap[entry]);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
// External modules — only those still referenced by a SURVIVING (non-dropped)
|
|
333
|
+
// file. An external introduced solely by a dropped file (e.g. eslint via
|
|
334
|
+
// eslint.config.js, vitest via a *.test.ts) must not leak onto the diagram.
|
|
335
|
+
// A workspace-remapped external must NOT materialize either — its edges now
|
|
336
|
+
// point at the sibling package's entry module, so an external box for it
|
|
337
|
+
// would be a phantom duplicate of an internal module.
|
|
338
|
+
const referencedExternals = new Set(graph.edges.filter((e) => e.external && fileSet.has(e.from)).map((e) => e.to));
|
|
339
|
+
for (const ext of graph.externals) {
|
|
340
|
+
if (!referencedExternals.has(ext.id))
|
|
341
|
+
continue;
|
|
342
|
+
if (workspaceEntryByExtId.has(ext.id))
|
|
343
|
+
continue;
|
|
344
|
+
modules.push({
|
|
345
|
+
id: ext.id,
|
|
346
|
+
kind: 'external',
|
|
347
|
+
fileIds: [],
|
|
348
|
+
externalSpecifier: ext.specifier,
|
|
349
|
+
fileCount: 0,
|
|
350
|
+
loc: 0,
|
|
351
|
+
degree: 0,
|
|
352
|
+
godNode: false,
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
// Aggregate file edges → module edges (preserve direction). Cross-package
|
|
356
|
+
// edges aggregate here like any other edge — full confidence, no penalty for
|
|
357
|
+
// having been excluded from community detection.
|
|
358
|
+
const moduleEdges = aggregateModuleEdges(graph, fileModuleMap, fileSet, workspaceEntryByExtId);
|
|
359
|
+
// declared cross-package dependency edges. The import graph only sees
|
|
360
|
+
// a package→package dependency when there's a resolved import; package.json
|
|
361
|
+
// sibling-workspace deps (collected as pkg.declaredDeps in workspaces.ts)
|
|
362
|
+
// declare the relation even when no import resolved. Materialize
|
|
363
|
+
// each as a structural `import` edge between the two packages' ENTRY modules
|
|
364
|
+
// (→ semantic `calls` downstream; assemble defaults the verb to `calls`). Only
|
|
365
|
+
// FILLS GAPS: a pair already joined by an aggregated import edge is left alone
|
|
366
|
+
// (no weight inflation). A self-edge is skipped silently; an UNRESOLVED entry
|
|
367
|
+
// module is skipped fail-soft but LOGGED (no silent caps — a missing edge must
|
|
368
|
+
// be distinguishable from "no dependency"). Deterministic: packages
|
|
369
|
+
// root-sorted, declaredDeps pre-sorted.
|
|
370
|
+
if (multiPackage && layout !== undefined) {
|
|
371
|
+
const pkgByRoot = new Map(layout.packages.map((p) => [p.root, p]));
|
|
372
|
+
const entryModuleOf = (pkg) => {
|
|
373
|
+
const entry = pkg.entryFileIds.find((f) => fileModuleMap[f] !== undefined);
|
|
374
|
+
return entry !== undefined ? fileModuleMap[entry] : undefined;
|
|
375
|
+
};
|
|
376
|
+
const existing = new Set(moduleEdges.map((e) => `${e.source} ${e.target}`));
|
|
377
|
+
const unresolved = []; // declared deps that couldn't anchor to an entry module
|
|
378
|
+
const sortedPkgs = [...layout.packages].sort((a, b) => a.root < b.root ? -1 : a.root > b.root ? 1 : 0);
|
|
379
|
+
for (const pkg of sortedPkgs) {
|
|
380
|
+
if (pkg.root === '' || pkg.declaredDeps.length === 0)
|
|
381
|
+
continue;
|
|
382
|
+
const src = entryModuleOf(pkg);
|
|
383
|
+
if (src === undefined) {
|
|
384
|
+
// pkg's own entry is unresolved → none of its declared deps can anchor.
|
|
385
|
+
for (const depRoot of pkg.declaredDeps)
|
|
386
|
+
unresolved.push(`${pkg.root}→${depRoot}`);
|
|
387
|
+
continue;
|
|
388
|
+
}
|
|
389
|
+
for (const depRoot of pkg.declaredDeps) {
|
|
390
|
+
const depPkg = pkgByRoot.get(depRoot);
|
|
391
|
+
if (depPkg === undefined)
|
|
392
|
+
continue; // structurally impossible (sibling root)
|
|
393
|
+
const tgt = entryModuleOf(depPkg);
|
|
394
|
+
if (tgt === undefined) {
|
|
395
|
+
unresolved.push(`${pkg.root}→${depRoot}`);
|
|
396
|
+
continue;
|
|
397
|
+
}
|
|
398
|
+
if (tgt === src)
|
|
399
|
+
continue; // legitimate self-edge, not an unresolved entry
|
|
400
|
+
const key = `${src} ${tgt}`;
|
|
401
|
+
if (existing.has(key))
|
|
402
|
+
continue;
|
|
403
|
+
existing.add(key);
|
|
404
|
+
moduleEdges.push({ source: src, target: tgt, weight: 1, kinds: ['import'] });
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
if (unresolved.length > 0) {
|
|
408
|
+
console.warn(` [workspaces] ${unresolved.length} declared cross-package dep(s) skipped — entry module unresolved: ${unresolved.join(', ')}`);
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
// Module-level degree + god-node flag. Degree counts distinct INTERNAL
|
|
412
|
+
// neighbours only — coupling to other internal modules is the scary-to-refactor
|
|
413
|
+
// signal. Counting external deps would flag any module that imports several
|
|
414
|
+
// libraries (a false positive that swamped the first dogfood run).
|
|
415
|
+
const internalIds = new Set(modules.filter((m) => m.kind === 'internal').map((m) => m.id));
|
|
416
|
+
const internalNodes = [...internalIds];
|
|
417
|
+
const neighbours = new Map();
|
|
418
|
+
for (const id of internalNodes)
|
|
419
|
+
setInto(neighbours, id);
|
|
420
|
+
for (const me of moduleEdges) {
|
|
421
|
+
if (!internalIds.has(me.source) || !internalIds.has(me.target))
|
|
422
|
+
continue;
|
|
423
|
+
neighbours.get(me.source).add(me.target);
|
|
424
|
+
neighbours.get(me.target).add(me.source);
|
|
425
|
+
}
|
|
426
|
+
for (const m of modules)
|
|
427
|
+
m.degree = neighbours.get(m.id)?.size ?? 0;
|
|
428
|
+
// Statistical god-node flag. Two conditions, ANDed:
|
|
429
|
+
// 1. internal-degree z-score > 1.5σ above the mean (an outlier in coupling)
|
|
430
|
+
// 2. betweenness centrality in the top quartile (a routing chokepoint)
|
|
431
|
+
// Only over the internal-module subgraph, and only when there are enough
|
|
432
|
+
// modules for the statistics to mean anything.
|
|
433
|
+
const godNodes = detectGodNodes(internalNodes, neighbours);
|
|
434
|
+
for (const m of modules)
|
|
435
|
+
m.godNode = m.kind === 'internal' && godNodes.has(m.id);
|
|
436
|
+
return { modules, fileModuleMap, moduleEdges };
|
|
437
|
+
}
|
|
438
|
+
// ---------------------------------------------------------------------------
|
|
439
|
+
// Module-id stabilization ( Stage C).
|
|
440
|
+
//
|
|
441
|
+
// WHY: module ids are the stable join key for the changelog AND the identity
|
|
442
|
+
// the enrich set-diff (enrich/diff.ts) reuses prior LLM labels by — a fresh
|
|
443
|
+
// cluster that produces the same community under a different derived id reads
|
|
444
|
+
// as "new module" downstream, which re-spends LLM tokens on a label we already
|
|
445
|
+
// have and churns the rendered topology for no real change. Stabilization
|
|
446
|
+
// greedily matches fresh internal modules to the prior extraction's internal
|
|
447
|
+
// modules by file-set overlap and adopts the prior ids.
|
|
448
|
+
//
|
|
449
|
+
// Rules (locked by the Stage C design):
|
|
450
|
+
// - candidate pairs (fresh internal × prior internal) scored by Jaccard
|
|
451
|
+
// overlap of fileId sets; sorted desc; accepted at ≥ 0.5, each fresh
|
|
452
|
+
// module and each prior id consumed at most once (greedy best-match);
|
|
453
|
+
// - override-FORCED modules are never renamed (their ids are user-authored)
|
|
454
|
+
// and their ids are never stolen by a match;
|
|
455
|
+
// - a module already bearing its matched prior id is not renamed (but the
|
|
456
|
+
// pair still consumes the id, so no other module can steal it);
|
|
457
|
+
// - on collision (a matched module adopts a prior id that ANOTHER fresh
|
|
458
|
+
// module derived naturally), the matched module wins; the other reroutes
|
|
459
|
+
// through the same `-2` dedup scheme deriveModuleId uses.
|
|
460
|
+
//
|
|
461
|
+
// Mutates `modules` (ids) and `fileModuleMap` in place, consistently —
|
|
462
|
+
// `packageId` and every other field are left intact. Callers must run this
|
|
463
|
+
// BEFORE aggregating module edges (clusterGraph does).
|
|
464
|
+
export function stabilizeModuleIds(modules, fileModuleMap, priorModules, opts) {
|
|
465
|
+
const forcedIds = opts?.forcedIds ?? new Set();
|
|
466
|
+
const priorInternal = priorModules.filter((m) => m.kind === 'internal');
|
|
467
|
+
if (priorInternal.length === 0)
|
|
468
|
+
return;
|
|
469
|
+
// Ids that can neither be renamed nor adopted: forced modules (user-
|
|
470
|
+
// authored) and any non-internal module a caller happens to pass. NOTE
|
|
471
|
+
// (REVIEWER PR #150): at clusterGraph's call site externals are appended to
|
|
472
|
+
// `modules` AFTER this runs, so they are NOT in this set there — external
|
|
473
|
+
// collisions are instead impossible by namespace (`ext:`-prefixed ids can
|
|
474
|
+
// never equal a slugified internal id; slugify strips ':'), and the guard
|
|
475
|
+
// below makes that invariant local rather than two files away.
|
|
476
|
+
const immovableIds = new Set();
|
|
477
|
+
for (const m of modules) {
|
|
478
|
+
if (m.kind !== 'internal' || forcedIds.has(m.id))
|
|
479
|
+
immovableIds.add(m.id);
|
|
480
|
+
}
|
|
481
|
+
const candidates = modules.filter((m) => m.kind === 'internal' && !forcedIds.has(m.id));
|
|
482
|
+
const pairs = [];
|
|
483
|
+
for (const mod of candidates) {
|
|
484
|
+
const fresh = new Set(mod.fileIds);
|
|
485
|
+
for (const prior of priorInternal) {
|
|
486
|
+
if (immovableIds.has(prior.id))
|
|
487
|
+
continue;
|
|
488
|
+
// Defense-in-depth (see immovableIds note): a namespaced id (`ext:…`)
|
|
489
|
+
// must never be adopted as an internal module id, regardless of what a
|
|
490
|
+
// caller put in priorModules or when externals join the modules array.
|
|
491
|
+
if (prior.id.includes(':'))
|
|
492
|
+
continue;
|
|
493
|
+
let inter = 0;
|
|
494
|
+
for (const f of prior.fileIds)
|
|
495
|
+
if (fresh.has(f))
|
|
496
|
+
inter++;
|
|
497
|
+
const union = fresh.size + prior.fileIds.length - inter;
|
|
498
|
+
const score = union === 0 ? 0 : inter / union;
|
|
499
|
+
if (score >= 0.5)
|
|
500
|
+
pairs.push({ mod, priorId: prior.id, score });
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
// Desc by score; ties broken by prior id then current id — fully
|
|
504
|
+
// deterministic regardless of input ordering (current ids are unique).
|
|
505
|
+
pairs.sort((a, b) => b.score - a.score ||
|
|
506
|
+
(a.priorId < b.priorId ? -1 : a.priorId > b.priorId ? 1 : 0) ||
|
|
507
|
+
(a.mod.id < b.mod.id ? -1 : a.mod.id > b.mod.id ? 1 : 0));
|
|
508
|
+
const usedPriorIds = new Set();
|
|
509
|
+
const matched = new Map(); // fresh module → adopted prior id
|
|
510
|
+
for (const p of pairs) {
|
|
511
|
+
if (usedPriorIds.has(p.priorId) || matched.has(p.mod))
|
|
512
|
+
continue;
|
|
513
|
+
usedPriorIds.add(p.priorId);
|
|
514
|
+
matched.set(p.mod, p.priorId);
|
|
515
|
+
}
|
|
516
|
+
if (matched.size === 0)
|
|
517
|
+
return;
|
|
518
|
+
// Compute final ids. Matched modules claim their prior ids first; immovable
|
|
519
|
+
// modules keep theirs; every remaining module keeps its current id unless a
|
|
520
|
+
// matched module claimed it — then it reroutes through the dedup suffix.
|
|
521
|
+
// Processing order is `modules` array order: deterministic.
|
|
522
|
+
const finalIds = new Map();
|
|
523
|
+
const used = new Set();
|
|
524
|
+
for (const m of modules) {
|
|
525
|
+
if (m.kind !== 'internal' || forcedIds.has(m.id)) {
|
|
526
|
+
finalIds.set(m, m.id);
|
|
527
|
+
used.add(m.id);
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
for (const [mod, priorId] of matched) {
|
|
531
|
+
finalIds.set(mod, priorId);
|
|
532
|
+
used.add(priorId);
|
|
533
|
+
}
|
|
534
|
+
for (const m of modules) {
|
|
535
|
+
if (finalIds.has(m))
|
|
536
|
+
continue;
|
|
537
|
+
finalIds.set(m, reserveId(m.id, used));
|
|
538
|
+
}
|
|
539
|
+
// Apply: rewrite module ids, then remap fileModuleMap by ORIGINAL value in
|
|
540
|
+
// one pass (original ids are unique, so a swap — A takes B's id while B
|
|
541
|
+
// reroutes — resolves correctly).
|
|
542
|
+
const idRemap = new Map();
|
|
543
|
+
for (const m of modules) {
|
|
544
|
+
const next = finalIds.get(m);
|
|
545
|
+
if (next !== m.id)
|
|
546
|
+
idRemap.set(m.id, next);
|
|
547
|
+
m.id = next;
|
|
548
|
+
}
|
|
549
|
+
if (idRemap.size === 0)
|
|
550
|
+
return;
|
|
551
|
+
for (const [fileId, moduleId] of Object.entries(fileModuleMap)) {
|
|
552
|
+
const next = idRemap.get(moduleId);
|
|
553
|
+
if (next !== undefined)
|
|
554
|
+
fileModuleMap[fileId] = next;
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
// Returns the set of internal module ids that are statistical god-nodes.
|
|
558
|
+
// Exported for unit testing (pure: adjacency in, flagged-set out).
|
|
559
|
+
export function detectGodNodes(nodes, adjacency) {
|
|
560
|
+
const flagged = new Set();
|
|
561
|
+
const n = nodes.length;
|
|
562
|
+
if (n < GOD_NODE_MIN_MODULES)
|
|
563
|
+
return flagged;
|
|
564
|
+
// (1) degree z-scores. Population std; if everyone has the same degree there
|
|
565
|
+
// is no outlier (std 0 → no flags).
|
|
566
|
+
const degrees = nodes.map((id) => adjacency.get(id)?.size ?? 0);
|
|
567
|
+
const mean = degrees.reduce((a, b) => a + b, 0) / n;
|
|
568
|
+
const variance = degrees.reduce((a, d) => a + (d - mean) ** 2, 0) / n;
|
|
569
|
+
const std = Math.sqrt(variance);
|
|
570
|
+
if (std === 0)
|
|
571
|
+
return flagged;
|
|
572
|
+
// (2) betweenness centrality (Brandes, unweighted undirected). The module
|
|
573
|
+
// graph is tiny (modules, not files), so the O(n·m) cost is negligible and an
|
|
574
|
+
// inline impl avoids pulling in graphology-metrics for ~25 lines.
|
|
575
|
+
const between = betweenness(nodes, adjacency);
|
|
576
|
+
const sorted = [...between.values()].sort((a, b) => a - b);
|
|
577
|
+
// Top-quartile cutoff = the 75th-percentile value. A node qualifies only if
|
|
578
|
+
// it's at/above the cutoff AND actually routes something (>0) — in a sparse
|
|
579
|
+
// graph many nodes have 0 betweenness and must not count as chokepoints.
|
|
580
|
+
const p75 = sorted[Math.floor(0.75 * (sorted.length - 1))];
|
|
581
|
+
for (const id of nodes) {
|
|
582
|
+
const z = ((adjacency.get(id)?.size ?? 0) - mean) / std;
|
|
583
|
+
const c = between.get(id) ?? 0;
|
|
584
|
+
if (z > GOD_NODE_Z_THRESHOLD && c > 0 && c >= p75)
|
|
585
|
+
flagged.add(id);
|
|
586
|
+
}
|
|
587
|
+
return flagged;
|
|
588
|
+
}
|
|
589
|
+
// Brandes' betweenness centrality for an unweighted, undirected graph.
|
|
590
|
+
// Deterministic (the centrality sums are independent of traversal order).
|
|
591
|
+
// Undirected → halve the final scores (each shortest path counted twice).
|
|
592
|
+
// Exported for unit testing.
|
|
593
|
+
export function betweenness(nodes, adjacency) {
|
|
594
|
+
const cb = new Map(nodes.map((id) => [id, 0]));
|
|
595
|
+
for (const s of nodes) {
|
|
596
|
+
const stack = [];
|
|
597
|
+
const pred = new Map(nodes.map((id) => [id, []]));
|
|
598
|
+
const sigma = new Map(nodes.map((id) => [id, 0]));
|
|
599
|
+
const dist = new Map(nodes.map((id) => [id, -1]));
|
|
600
|
+
sigma.set(s, 1);
|
|
601
|
+
dist.set(s, 0);
|
|
602
|
+
const queue = [s];
|
|
603
|
+
while (queue.length > 0) {
|
|
604
|
+
const v = queue.shift();
|
|
605
|
+
stack.push(v);
|
|
606
|
+
for (const w of adjacency.get(v) ?? []) {
|
|
607
|
+
if (dist.get(w) < 0) {
|
|
608
|
+
dist.set(w, dist.get(v) + 1);
|
|
609
|
+
queue.push(w);
|
|
610
|
+
}
|
|
611
|
+
if (dist.get(w) === dist.get(v) + 1) {
|
|
612
|
+
sigma.set(w, sigma.get(w) + sigma.get(v));
|
|
613
|
+
pred.get(w).push(v);
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
const delta = new Map(nodes.map((id) => [id, 0]));
|
|
618
|
+
while (stack.length > 0) {
|
|
619
|
+
const w = stack.pop();
|
|
620
|
+
for (const v of pred.get(w)) {
|
|
621
|
+
delta.set(v, delta.get(v) + (sigma.get(v) / sigma.get(w)) * (1 + delta.get(w)));
|
|
622
|
+
}
|
|
623
|
+
if (w !== s)
|
|
624
|
+
cb.set(w, cb.get(w) + delta.get(w));
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
for (const id of nodes)
|
|
628
|
+
cb.set(id, cb.get(id) / 2);
|
|
629
|
+
return cb;
|
|
630
|
+
}
|
|
631
|
+
function setInto(map, key) {
|
|
632
|
+
const s = new Set();
|
|
633
|
+
map.set(key, s);
|
|
634
|
+
return s;
|
|
635
|
+
}
|
|
636
|
+
function makeInternalModule(id, fileIds, locById) {
|
|
637
|
+
return {
|
|
638
|
+
id,
|
|
639
|
+
kind: 'internal',
|
|
640
|
+
fileIds,
|
|
641
|
+
fileCount: fileIds.length,
|
|
642
|
+
loc: fileIds.reduce((sum, f) => sum + (locById.get(f) ?? 0), 0),
|
|
643
|
+
degree: 0,
|
|
644
|
+
godNode: false,
|
|
645
|
+
};
|
|
646
|
+
}
|
|
647
|
+
function aggregateModuleEdges(graph, fileModuleMap, fileSet,
|
|
648
|
+
// Stage C: external id → workspace sibling's entry module id. A
|
|
649
|
+
// matching external edge becomes an INTERNAL module edge (kinds preserved,
|
|
650
|
+
// merged with any existing edge to the same pair). Self-edges (a package
|
|
651
|
+
// importing its own name) fall out via the src === tgt guard.
|
|
652
|
+
externalRemap) {
|
|
653
|
+
const edges = new Map();
|
|
654
|
+
for (const e of graph.edges) {
|
|
655
|
+
const src = fileModuleMap[e.from];
|
|
656
|
+
if (!src || !fileSet.has(e.from))
|
|
657
|
+
continue;
|
|
658
|
+
const tgt = e.external ? (externalRemap?.get(e.to) ?? e.to) : fileModuleMap[e.to];
|
|
659
|
+
if (!tgt || src === tgt)
|
|
660
|
+
continue;
|
|
661
|
+
const key = `${src} ${tgt}`;
|
|
662
|
+
const existing = edges.get(key);
|
|
663
|
+
if (existing) {
|
|
664
|
+
existing.weight += e.weight;
|
|
665
|
+
if (!existing.kinds.includes(e.kind))
|
|
666
|
+
existing.kinds.push(e.kind);
|
|
667
|
+
}
|
|
668
|
+
else {
|
|
669
|
+
edges.set(key, { source: src, target: tgt, weight: e.weight, kinds: [e.kind] });
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
return [...edges.values()];
|
|
673
|
+
}
|
|
674
|
+
//# sourceMappingURL=louvain.js.map
|