simt-dev 0.2.0__py3-none-any.whl
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.
- python/simt_dev/__init__.py +32 -0
- python/simt_dev/cli.py +225 -0
- simt_dev/__init__.py +32 -0
- simt_dev/cli.py +225 -0
- simt_dev/vendored/CHANGELOG.md +166 -0
- simt_dev/vendored/bin/simt-dev.mjs +28 -0
- simt_dev/vendored/docs/decisions/ADR-001-push-requires-approval.md +42 -0
- simt_dev/vendored/docs/decisions/ADR-002-refactor-blast-radius.md +45 -0
- simt_dev/vendored/docs/decisions/ADR-003-test-breadth.md +46 -0
- simt_dev/vendored/docs/decisions/ADR-004-hook-owns-format.md +45 -0
- simt_dev/vendored/docs/decisions/ADR-005-file-length.md +42 -0
- simt_dev/vendored/docs/decisions/ADR-006-coverage-bar.md +59 -0
- simt_dev/vendored/docs/decisions/ADR-007-section-skeleton.md +54 -0
- simt_dev/vendored/docs/decisions/ADR-008-verdict-vocabulary.md +59 -0
- simt_dev/vendored/docs/decisions/ADR-009-skill-layout.md +48 -0
- simt_dev/vendored/docs/decisions/ADR-010-runner-invocation.md +56 -0
- simt_dev/vendored/docs/decisions/ADR-011-windows-support.md +52 -0
- simt_dev/vendored/docs/decisions/ADR-012-drop-windows-support.md +59 -0
- simt_dev/vendored/docs/decisions/ADR-013-genericity-by-facts-and-local-profiles.md +114 -0
- simt_dev/vendored/docs/decisions/ADR-014-workspace-is-configuration.md +211 -0
- simt_dev/vendored/docs/decisions/ADR-015-a-source-is-content-not-a-location.md +117 -0
- simt_dev/vendored/docs/decisions/ADR-016-a-divergent-rule-is-retired-not-merged.md +180 -0
- simt_dev/vendored/docs/decisions/ADR-017-a-declared-gate-must-be-runnable.md +89 -0
- simt_dev/vendored/docs/decisions/ADR-018-an-unset-hooks-path-is-not-an-absent-hook.md +183 -0
- simt_dev/vendored/docs/decisions/ADR-019-a-declared-command-is-the-runner.md +110 -0
- simt_dev/vendored/docs/decisions/ADR-020-a-rule-is-written-per-surface-and-pointed-not-copied.md +100 -0
- simt_dev/vendored/docs/decisions/ADR-021-one-surface-per-repo.md +94 -0
- simt_dev/vendored/docs/decisions/ADR-022-a-key-simt-dev-reads-is-prefixed.md +98 -0
- simt_dev/vendored/docs/decisions/ADR-023-the-gate-ships-as-a-file-the-repo-owns.md +106 -0
- simt_dev/vendored/docs/decisions/ADR-024-simt-dev-overwrites-its-own-gate-file.md +111 -0
- simt_dev/vendored/docs/decisions/ADR-025-a-baseline-is-a-region-in-a-file-the-repo-owns.md +153 -0
- simt_dev/vendored/docs/decisions/ADR-026-a-config-without-a-region-is-a-file-the-repo-owns.md +117 -0
- simt_dev/vendored/docs/decisions/ADR-027-the-one-gitattributes-line-that-is-neither-dangerous-nor-empty.md +130 -0
- simt_dev/vendored/docs/decisions/ADR-028-a-base-file-is-a-floor-so-the-disagreement-it-cannot-repair-becomes-a-check.md +131 -0
- simt_dev/vendored/docs/decisions/ADR-029-the-pins-a-repository-declares-are-its-own.md +120 -0
- simt_dev/vendored/docs/decisions/ADR-030-what-a-plan-does-not-ship-is-a-decision-with-a-record.md +114 -0
- simt_dev/vendored/docs/decisions/ADR-031-the-suite-stages-the-environment-it-needs.md +129 -0
- simt_dev/vendored/docs/decisions/ADR-032-provenance-is-a-content-digest-not-a-commit.md +141 -0
- simt_dev/vendored/docs/decisions/ADR-033-three-plan-rows-closed-by-decision-not-by-authoring.md +82 -0
- simt_dev/vendored/docs/decisions/ADR-034-a-staged-tool-refuses-a-partially-staged-file.md +91 -0
- simt_dev/vendored/docs/decisions/ADR-035-one-write-decision-and-one-json-document.md +119 -0
- simt_dev/vendored/docs/decisions/ADR-036-a-writer-that-writes-says-so-and-the-instructions-naming-it-move-with-it.md +103 -0
- simt_dev/vendored/docs/decisions/ADR-037-unchanged-means-writing-would-not-change-a-byte.md +100 -0
- simt_dev/vendored/docs/decisions/ADR-038-the-gate-filter-belongs-to-every-manifest-writer.md +95 -0
- simt_dev/vendored/docs/decisions/ADR-039-a-ci-step-runs-a-form-that-can-fail.md +101 -0
- simt_dev/vendored/docs/decisions/ADR-040-the-last-writer-that-wrote-by-default-was-not-the-one-named.md +158 -0
- simt_dev/vendored/docs/decisions/ADR-041-the-suite-installs-the-package-in-order-to-test-it.md +92 -0
- simt_dev/vendored/docs/decisions/ADR-042-a-declared-glob-must-match-a-path-built-from-it.md +94 -0
- simt_dev/vendored/docs/decisions/ADR-043-a-keyword-is-a-phrase-and-two-letters-is-a-word.md +90 -0
- simt_dev/vendored/docs/decisions/ADR-044-compatibility-is-not-evidence.md +116 -0
- simt_dev/vendored/docs/decisions/ADR-045-a-dashboard-that-counts-a-defect.md +119 -0
- simt_dev/vendored/docs/decisions/ADR-046-one-rule-one-copy.md +140 -0
- simt_dev/vendored/docs/decisions/ADR-047-a-profile-selects-on-what-a-repository-runs-on.md +141 -0
- simt_dev/vendored/docs/decisions/ADR-048-a-baseline-carries-only-keys-a-reader-honours.md +150 -0
- simt_dev/vendored/docs/decisions/ADR-049-a-ruling-that-a-later-record-moved-says-so-where-it-is-printed.md +153 -0
- simt_dev/vendored/docs/decisions/ADR-050-a-byte-count-is-not-a-region-and-updated-is-not-a-change-of-rules.md +102 -0
- simt_dev/vendored/docs/decisions/ADR-051-a-pointer-is-the-readers-only-route-so-it-is-not-a-convenience.md +134 -0
- simt_dev/vendored/docs/decisions/ADR-052-a-dependency-is-counted-against-the-budget-and-there-is-one-budget.md +101 -0
- simt_dev/vendored/docs/decisions/ADR-053-the-node-floor-is-22-20-0.md +49 -0
- simt_dev/vendored/docs/decisions/ADR-054-the-digest-covers-every-byte-that-leaves-the-package.md +100 -0
- simt_dev/vendored/docs/decisions/ADR-055-a-status-line-travels-so-it-may-not-name-a-location.md +98 -0
- simt_dev/vendored/docs/decisions/ADR-056-three-names-one-of-which-is-cheap-to-move.md +160 -0
- simt_dev/vendored/docs/decisions/ADR-057-an-identity-is-per-developer-and-a-repository-does-not-own-it.md +115 -0
- simt_dev/vendored/docs/decisions/ADR-058-one-tool-three-channels-and-the-order-is-forced.md +148 -0
- simt_dev/vendored/docs/decisions/README.md +530 -0
- simt_dev/vendored/graph/skills.graph.json +1622 -0
- simt_dev/vendored/package.json +43 -0
- simt_dev/vendored/rules/core.md +76 -0
- simt_dev/vendored/rules/mdc/intake-questions.mdc +50 -0
- simt_dev/vendored/rules/mdc/preview-postflight.mdc +61 -0
- simt_dev/vendored/rules/mdc/slice-commit.mdc +52 -0
- simt_dev/vendored/rules/mdc/test-gate.mdc +54 -0
- simt_dev/vendored/rules/stacks/android.md +63 -0
- simt_dev/vendored/rules/stacks/cloudflare-workers.md +65 -0
- simt_dev/vendored/rules/stacks/connectiq.md +62 -0
- simt_dev/vendored/rules/stacks/container.md +31 -0
- simt_dev/vendored/rules/stacks/jvm.md +54 -0
- simt_dev/vendored/rules/stacks/node.md +45 -0
- simt_dev/vendored/rules/stacks/python.md +48 -0
- simt_dev/vendored/rules/stacks/react-native.md +58 -0
- simt_dev/vendored/rules/stacks/react-web.md +61 -0
- simt_dev/vendored/rules/stacks/typescript.md +21 -0
- simt_dev/vendored/rules/stacks/uv.md +32 -0
- simt_dev/vendored/src/argv.mjs +116 -0
- simt_dev/vendored/src/ci/dependabot.mjs +268 -0
- simt_dev/vendored/src/ci/workflow.mjs +360 -0
- simt_dev/vendored/src/commands/ci.mjs +285 -0
- simt_dev/vendored/src/commands/compare.mjs +195 -0
- simt_dev/vendored/src/commands/graph.mjs +73 -0
- simt_dev/vendored/src/commands/help.mjs +108 -0
- simt_dev/vendored/src/commands/hooks.mjs +471 -0
- simt_dev/vendored/src/commands/index.mjs +241 -0
- simt_dev/vendored/src/commands/init.mjs +632 -0
- simt_dev/vendored/src/commands/mcp.mjs +63 -0
- simt_dev/vendored/src/commands/rules.mjs +374 -0
- simt_dev/vendored/src/commands/skills.mjs +647 -0
- simt_dev/vendored/src/commands/skillsinstall.mjs +515 -0
- simt_dev/vendored/src/commands/sync.mjs +331 -0
- simt_dev/vendored/src/commands/upgrade.mjs +202 -0
- simt_dev/vendored/src/compare/discover.mjs +212 -0
- simt_dev/vendored/src/compare/hooksstate.mjs +159 -0
- simt_dev/vendored/src/compare/row.mjs +299 -0
- simt_dev/vendored/src/decisions/index.mjs +536 -0
- simt_dev/vendored/src/graph/load.mjs +541 -0
- simt_dev/vendored/src/hooks/capability.mjs +129 -0
- simt_dev/vendored/src/hooks/dispatcher.mjs +268 -0
- simt_dev/vendored/src/hooks/gates.mjs +144 -0
- simt_dev/vendored/src/hooks/resolve.mjs +194 -0
- simt_dev/vendored/src/hooks/shim.mjs +140 -0
- simt_dev/vendored/src/hooks/where.mjs +118 -0
- simt_dev/vendored/src/identity.mjs +218 -0
- simt_dev/vendored/src/locate.mjs +75 -0
- simt_dev/vendored/src/mcp/server.mjs +167 -0
- simt_dev/vendored/src/mcp/tools.mjs +410 -0
- simt_dev/vendored/src/name.mjs +95 -0
- simt_dev/vendored/src/output/index.mjs +96 -0
- simt_dev/vendored/src/projection/baselines.mjs +209 -0
- simt_dev/vendored/src/projection/conflicts.mjs +205 -0
- simt_dev/vendored/src/projection/manifest.mjs +478 -0
- simt_dev/vendored/src/projection/markers.mjs +349 -0
- simt_dev/vendored/src/projection/mdc.mjs +327 -0
- simt_dev/vendored/src/projection/project.mjs +504 -0
- simt_dev/vendored/src/projection/rules.mjs +640 -0
- simt_dev/vendored/src/projection/surfaces.mjs +398 -0
- simt_dev/vendored/src/projection/targets.mjs +221 -0
- simt_dev/vendored/src/resolve/fingerprint.mjs +553 -0
- simt_dev/vendored/src/resolve/near.mjs +61 -0
- simt_dev/vendored/src/resolve/resolve.mjs +457 -0
- simt_dev/vendored/src/resolve/source.mjs +105 -0
- simt_dev/vendored/src/toolchain.mjs +202 -0
- simt_dev/vendored/src/tsconfig.mjs +314 -0
- simt_dev/vendored/src/upgrade/plan.mjs +203 -0
- simt_dev/vendored/src/workspace.mjs +289 -0
- simt_dev-0.2.0.dist-info/METADATA +150 -0
- simt_dev-0.2.0.dist-info/RECORD +138 -0
- simt_dev-0.2.0.dist-info/WHEEL +4 -0
- simt_dev-0.2.0.dist-info/entry_points.txt +2 -0
- simt_dev-0.2.0.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,553 @@
|
|
|
1
|
+
// Classify a repo from what is actually in the working tree.
|
|
2
|
+
//
|
|
3
|
+
// This is the fallback that makes a new repo classifiable with no configuration:
|
|
4
|
+
// detection gets you most of the way, and simt-dev.json exists to make the
|
|
5
|
+
// remainder explicit and reviewable (ADR: 0002 section 4.1).
|
|
6
|
+
//
|
|
7
|
+
// Two kinds of signal come out, and the distinction is load-bearing
|
|
8
|
+
// (ADR-013). `stack` names a platform, and is keyed by curated product and
|
|
9
|
+
// library names - precise, and dead the day the library is swapped. `facts`
|
|
10
|
+
// name an observable property of the tree (`has-cargo-toml`), are derived from
|
|
11
|
+
// evidence rather than from a taxonomy, and therefore need no table growth when
|
|
12
|
+
// a new ecosystem arrives: the fact was already being observed. A profile may
|
|
13
|
+
// select on either, so supporting an unknown ecosystem is a profile file and
|
|
14
|
+
// not a code change.
|
|
15
|
+
//
|
|
16
|
+
// POSIX only (ADR-012). Marker paths are normalised to forward slashes anyway,
|
|
17
|
+
// because a marker set written by hand in the registry must match what readdir
|
|
18
|
+
// returns on any host.
|
|
19
|
+
|
|
20
|
+
import { readdir, readFile, stat } from 'node:fs/promises';
|
|
21
|
+
import { spawn } from 'node:child_process';
|
|
22
|
+
import { basename, join } from 'node:path';
|
|
23
|
+
|
|
24
|
+
import { matchesGlob } from './resolve.mjs';
|
|
25
|
+
import { MANIFEST_FILE, MANIFEST_LEGACY } from '../name.mjs';
|
|
26
|
+
|
|
27
|
+
/** tag -> human-readable marker(s) that imply it. First match wins per tag. */
|
|
28
|
+
const MARKERS = [
|
|
29
|
+
['connectiq', ['monkey.jungle', 'manifest.xml']],
|
|
30
|
+
// `wrangler.json` was missing here, so a Worker configured with the plain JSON
|
|
31
|
+
// config and no `wrangler` dependency had no evidence of Workers at all - the
|
|
32
|
+
// profile stopped applying to it (S28).
|
|
33
|
+
['cloudflare-workers', ['wrangler.json', 'wrangler.jsonc', 'wrangler.toml']],
|
|
34
|
+
['python', ['pyproject.toml', 'requirements.txt', 'setup.py']],
|
|
35
|
+
['gradle', ['build.gradle.kts', 'build.gradle', 'settings.gradle.kts']],
|
|
36
|
+
['node-web', ['vite.config.ts', 'vite.config.js', 'vite.config.mjs']],
|
|
37
|
+
['node', ['package.json']],
|
|
38
|
+
['less', ['styles.less']],
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Extensions are weaker evidence than marker files: a stray script must not
|
|
43
|
+
* define a repo's stack. `python` is deliberately absent here because
|
|
44
|
+
* a repository carries .py tooling scripts inside a Gradle project - a marker file
|
|
45
|
+
* is required for that tag. `kotlin` has no marker file, so it stays here.
|
|
46
|
+
*/
|
|
47
|
+
const EXTENSION_TAGS = new Map([
|
|
48
|
+
['.mc', 'connectiq'],
|
|
49
|
+
['.kt', 'kotlin'],
|
|
50
|
+
['.kts', 'gradle'],
|
|
51
|
+
['.java', 'java'],
|
|
52
|
+
['.ts', 'typescript'],
|
|
53
|
+
['.tsx', 'react'],
|
|
54
|
+
['.less', 'less'],
|
|
55
|
+
]);
|
|
56
|
+
|
|
57
|
+
const DEP_TAGS = new Map([
|
|
58
|
+
['react', 'react'],
|
|
59
|
+
// `react-dom` is the renderer, so its presence is what says "a browser". Mapping it
|
|
60
|
+
// onto `react` was what let a React Native app and a React web app arrive with the
|
|
61
|
+
// same tag and receive the same profile (S28).
|
|
62
|
+
['react-dom', 'react-dom'],
|
|
63
|
+
['react-native', 'react-native'],
|
|
64
|
+
['expo', 'react-native'],
|
|
65
|
+
['@react-router/dev', 'react-router'],
|
|
66
|
+
['firebase', 'firebase'],
|
|
67
|
+
['firebase-admin', 'firebase'],
|
|
68
|
+
['maplibre-gl', 'maplibre'],
|
|
69
|
+
// `hono` stays a tag: it is the discriminator between the API and the web half of a
|
|
70
|
+
// deployment, and nine registry skills name it. It stopped being *evidence of
|
|
71
|
+
// Workers* - that was a profile's choice, not the detector's (S28, ADR-013 r1).
|
|
72
|
+
['hono', 'hono'],
|
|
73
|
+
['wrangler', 'cloudflare-workers'],
|
|
74
|
+
['typescript', 'typescript'],
|
|
75
|
+
['less', 'less'],
|
|
76
|
+
['vitest', 'vitest'],
|
|
77
|
+
['playwright', 'playwright'],
|
|
78
|
+
]);
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* How many directory levels below the root a `glob` fact may look, counted in
|
|
82
|
+
* directories descended.
|
|
83
|
+
*
|
|
84
|
+
* Three, and the reason is a convention rather than a preference: the Android Gradle
|
|
85
|
+
* Plugin puts a module's manifest at `<module>/src/main/AndroidManifest.xml`, and
|
|
86
|
+
* one common layout sits at `app/src/main/AndroidManifest.xml`. A bound shallower than the
|
|
87
|
+
* convention makes `has-android-manifest` the shape S07 found - a fact no repository
|
|
88
|
+
* could ever have. Deeper would be a scan rather than a probe, and ADR-013's promise
|
|
89
|
+
* is that adding an ecosystem is data, so a fact must not cost a search.
|
|
90
|
+
*
|
|
91
|
+
* The extension sample below reads tracked paths rather than a bounded number of
|
|
92
|
+
* directory levels, because depth was never the question. It asks "is this repo written
|
|
93
|
+
* in Kotlin", and a language's sources sit where its build system puts them —
|
|
94
|
+
* `app/src/main/kotlin/…` is already past two levels — so a walk bounded to two levels
|
|
95
|
+
* never reached a standard source set. The bound that replaced it is a count of paths,
|
|
96
|
+
* not a depth, and it is stated where it is enforced. A `glob` fact is different: it
|
|
97
|
+
* asserts that a specific path exists, and a fact that cannot see the path is wrong
|
|
98
|
+
* when the path is there.
|
|
99
|
+
*/
|
|
100
|
+
const GLOB_DEPTH = 3;
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Observable properties of the working tree, named `has-*`.
|
|
104
|
+
*
|
|
105
|
+
* Every entry is either a path that exists or a predicate over package.json, so
|
|
106
|
+
* adding an ecosystem means writing a profile against facts that are already
|
|
107
|
+
* emitted - not editing this table. Keep entries boring and opinion-free: a
|
|
108
|
+
* manifest, a lockfile, a directory convention. If an entry starts encoding "and
|
|
109
|
+
* therefore it is a Django project", it belongs in a profile instead.
|
|
110
|
+
*
|
|
111
|
+
* kind 'file' and 'dir' probe the repository root; 'path' stats one nested file
|
|
112
|
+
* (including under a dot directory a glob walk skips); 'pkg' reads the parsed
|
|
113
|
+
* manifest; `glob` walks at most `GLOB_DEPTH` directory levels looking for a shape.
|
|
114
|
+
* `paths` is alternatives - first match wins and is reported as evidence, so `doctor`
|
|
115
|
+
* can say which file proved the fact.
|
|
116
|
+
*
|
|
117
|
+
* A candidate is probed by its declared kind, so a `dir` may name a nested path.
|
|
118
|
+
* A `file` candidate is matched against the root listing, which cannot see past
|
|
119
|
+
* the root, so it must not contain a `/` - `test/genericity.test.mjs` fails if one
|
|
120
|
+
* does, rather than letting it sit there never matching. A `dir` candidate that
|
|
121
|
+
* was probed with `isFile` instead (S07) looked like a fact nobody's repo could
|
|
122
|
+
* have: seven estate repositories have `.github/workflows/` and none of them
|
|
123
|
+
* emitted `has-ci-workflows`.
|
|
124
|
+
*
|
|
125
|
+
* A `glob` candidate is the third kind, for the paths neither of the others can
|
|
126
|
+
* reach: it names a shape at an unknown depth, so it must contain a star-star and
|
|
127
|
+
* `test/genericity.test.mjs` fails if it does not. Evidence for a `glob` is the
|
|
128
|
+
* file that matched, never the pattern - `doctor` answers "why did you think
|
|
129
|
+
* that?", and a shape is not an answer.
|
|
130
|
+
*/
|
|
131
|
+
const FACTS = [
|
|
132
|
+
{ name: 'has-pyproject', kind: 'file', paths: ['pyproject.toml'] },
|
|
133
|
+
{ name: 'has-requirements', kind: 'file', paths: ['requirements.txt'] },
|
|
134
|
+
{ name: 'has-go-mod', kind: 'file', paths: ['go.mod'] },
|
|
135
|
+
{ name: 'has-cargo-toml', kind: 'file', paths: ['Cargo.toml'] },
|
|
136
|
+
{ name: 'has-gemfile', kind: 'file', paths: ['Gemfile'] },
|
|
137
|
+
{ name: 'has-composer', kind: 'file', paths: ['composer.json'] },
|
|
138
|
+
{ name: 'has-maven-pom', kind: 'file', paths: ['pom.xml'] },
|
|
139
|
+
{ name: 'has-build-system', kind: 'file', paths: ['Makefile', 'makefile', 'CMakeLists.txt', 'meson.build'] },
|
|
140
|
+
{ name: 'has-dockerfile', kind: 'file', paths: ['Dockerfile', 'Containerfile'] },
|
|
141
|
+
{
|
|
142
|
+
name: 'has-compose',
|
|
143
|
+
kind: 'file',
|
|
144
|
+
paths: ['docker-compose.yml', 'docker-compose.yaml', 'compose.yml', 'compose.yaml'],
|
|
145
|
+
},
|
|
146
|
+
{ name: 'has-terraform', kind: 'file', paths: ['main.tf', 'versions.tf'] },
|
|
147
|
+
{ name: 'has-migrations-dir', kind: 'dir', paths: ['migrations', 'migrate', 'db/migrate'] },
|
|
148
|
+
{ name: 'has-schema-dir', kind: 'dir', paths: ['schema', 'schemas', 'db/schema'] },
|
|
149
|
+
{ name: 'has-tests-dir', kind: 'dir', paths: ['test', 'tests', '__tests__', 'spec', 'e2e'] },
|
|
150
|
+
{ name: 'has-decisions-dir', kind: 'dir', paths: ['docs/decisions', 'docs/adr', 'docs/adrs', 'adr'] },
|
|
151
|
+
{ name: 'has-ci-workflows', kind: 'dir', paths: ['.github/workflows', '.gitlab-ci.d'] },
|
|
152
|
+
{
|
|
153
|
+
name: 'has-lockfile',
|
|
154
|
+
kind: 'file',
|
|
155
|
+
paths: [
|
|
156
|
+
'package-lock.json',
|
|
157
|
+
'pnpm-lock.yaml',
|
|
158
|
+
'yarn.lock',
|
|
159
|
+
'bun.lock',
|
|
160
|
+
'bun.lockb',
|
|
161
|
+
'uv.lock',
|
|
162
|
+
'poetry.lock',
|
|
163
|
+
'Cargo.lock',
|
|
164
|
+
'go.sum',
|
|
165
|
+
'Gemfile.lock',
|
|
166
|
+
],
|
|
167
|
+
},
|
|
168
|
+
{ name: 'has-license', kind: 'file', paths: ['LICENSE', 'LICENSE.md', 'LICENCE', 'COPYING'] },
|
|
169
|
+
{ name: 'has-readme', kind: 'file', paths: ['README.md', 'README.rst', 'README'] },
|
|
170
|
+
{ name: 'has-node-workspace', kind: 'file', paths: ['pnpm-workspace.yaml'] },
|
|
171
|
+
// The two facts below are what S28's profile split needs, and both name a shape at
|
|
172
|
+
// an unknown depth. `has-uv-lock` matches the root `uv.lock` too, because the
|
|
173
|
+
// star-star stands for zero directories as well as many.
|
|
174
|
+
{ name: 'has-uv-lock', kind: 'glob', paths: ['**/uv.lock'] },
|
|
175
|
+
{ name: 'has-android-manifest', kind: 'glob', paths: ['**/src/main/AndroidManifest.xml'] },
|
|
176
|
+
{ name: 'has-submodules', kind: 'file', paths: ['.gitmodules'] },
|
|
177
|
+
{ name: 'has-nuget', kind: 'glob', paths: ['**/*.csproj', '**/*.sln'] },
|
|
178
|
+
{ name: 'has-pubspec', kind: 'file', paths: ['pubspec.yaml'] },
|
|
179
|
+
{ name: 'has-swift-package', kind: 'file', paths: ['Package.swift'] },
|
|
180
|
+
{ name: 'has-mix', kind: 'file', paths: ['mix.exs'] },
|
|
181
|
+
{ name: 'has-bun-lock', kind: 'file', paths: ['bun.lock', 'bun.lockb'] },
|
|
182
|
+
// A glob walk skips dot directories, and `.devcontainer` is one. Stat the path.
|
|
183
|
+
{ name: 'has-devcontainer', kind: 'path', paths: ['.devcontainer/devcontainer.json'] },
|
|
184
|
+
];
|
|
185
|
+
|
|
186
|
+
/** Facts derived from the parsed manifest rather than from the file listing. */
|
|
187
|
+
const PKG_FACTS = [
|
|
188
|
+
// A fact that merely restates an existing stack tag is a second name for one
|
|
189
|
+
// observation, which is the kind of duplication the estate blocks in review.
|
|
190
|
+
// `typescript` is already a stack tag from DEP_TAGS, so no `has-typescript`.
|
|
191
|
+
{ name: 'has-node-workspace', test: (pkg) => Boolean(pkg.workspaces) },
|
|
192
|
+
];
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* @param {string} root absolute path to a repository root
|
|
196
|
+
* @returns {Promise<{stack: string[], facts: string[], factEvidence: Record<string,string>,
|
|
197
|
+
* evidence: string[], manifest: object|null, error?: string}>}
|
|
198
|
+
*/
|
|
199
|
+
export async function fingerprint(root) {
|
|
200
|
+
const stack = new Set();
|
|
201
|
+
const facts = new Set();
|
|
202
|
+
const factEvidence = {};
|
|
203
|
+
const evidence = [];
|
|
204
|
+
let entries = [];
|
|
205
|
+
let pkg = null;
|
|
206
|
+
|
|
207
|
+
try {
|
|
208
|
+
entries = await readdir(root);
|
|
209
|
+
} catch {
|
|
210
|
+
return {
|
|
211
|
+
stack: [],
|
|
212
|
+
facts: [],
|
|
213
|
+
signals: [],
|
|
214
|
+
factEvidence: {},
|
|
215
|
+
evidence: [],
|
|
216
|
+
manifest: null,
|
|
217
|
+
error: 'unreadable-root',
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
const names = new Set(entries);
|
|
221
|
+
|
|
222
|
+
for (const [tag, markers] of MARKERS) {
|
|
223
|
+
const hit = markers.find((m) => names.has(m));
|
|
224
|
+
if (hit) {
|
|
225
|
+
stack.add(tag);
|
|
226
|
+
evidence.push(hit);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Facts first, because two of them need the manifest and parsing it twice
|
|
231
|
+
// would be wasteful. A fact is recorded only on positive evidence; an
|
|
232
|
+
// unparseable manifest yields a fact's absence, not a wrong answer.
|
|
233
|
+
for (const fact of FACTS) {
|
|
234
|
+
for (const candidate of fact.paths) {
|
|
235
|
+
if (fact.kind === 'path') {
|
|
236
|
+
try {
|
|
237
|
+
await stat(join(root, candidate));
|
|
238
|
+
} catch {
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
facts.add(fact.name);
|
|
242
|
+
factEvidence[fact.name] ??= candidate;
|
|
243
|
+
break;
|
|
244
|
+
}
|
|
245
|
+
if (fact.kind === 'glob') {
|
|
246
|
+
const hit = (await findNested(root, candidate))[0];
|
|
247
|
+
if (hit) {
|
|
248
|
+
facts.add(fact.name);
|
|
249
|
+
factEvidence[fact.name] ??= hit;
|
|
250
|
+
break;
|
|
251
|
+
}
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
const present = fact.kind === 'file' ? names.has(candidate) : await isDir(join(root, candidate));
|
|
255
|
+
if (present) {
|
|
256
|
+
facts.add(fact.name);
|
|
257
|
+
factEvidence[fact.name] ??= candidate;
|
|
258
|
+
if (fact.name === 'has-node-workspace' && fact.kind === 'file') {
|
|
259
|
+
evidence.push(candidate);
|
|
260
|
+
}
|
|
261
|
+
break;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Extensions. The bound, and why it is a count of paths rather than a depth, is
|
|
267
|
+
// on sampleExtensions.
|
|
268
|
+
const sampled = await sampleExtensions(root);
|
|
269
|
+
for (const ext of sampled) {
|
|
270
|
+
const tag = EXTENSION_TAGS.get(ext);
|
|
271
|
+
if (tag) {
|
|
272
|
+
stack.add(tag);
|
|
273
|
+
evidence.push(ext);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// package.json dependencies are the highest-signal source for a JS/TS repo.
|
|
278
|
+
if (names.has('package.json')) {
|
|
279
|
+
try {
|
|
280
|
+
pkg = JSON.parse(await readFile(join(root, 'package.json'), 'utf8'));
|
|
281
|
+
for (const field of ['dependencies', 'devDependencies']) {
|
|
282
|
+
for (const dep of Object.keys(pkg[field] ?? {})) {
|
|
283
|
+
const tag = DEP_TAGS.get(dep);
|
|
284
|
+
if (tag) {
|
|
285
|
+
stack.add(tag);
|
|
286
|
+
evidence.push(dep);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
} catch {
|
|
291
|
+
evidence.push('package.json:unparseable');
|
|
292
|
+
pkg = null;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
for (const fact of PKG_FACTS) {
|
|
297
|
+
if (pkg && fact.test(pkg)) {
|
|
298
|
+
facts.add(fact.name);
|
|
299
|
+
factEvidence[fact.name] ??= 'package.json';
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// two repositories both carry a wear/ module; only one of them is the wearable.
|
|
304
|
+
if (names.has('wear') && (await isDir(join(root, 'wear')))) {
|
|
305
|
+
stack.add('wear-os');
|
|
306
|
+
evidence.push('wear/');
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Either spelling: a repository that adopted this tool has one of the two on disk,
|
|
310
|
+
// and a fingerprint that missed the manifest would silently lose the repo's own
|
|
311
|
+
// declared stack - which is the one input no amount of marker reading can replace.
|
|
312
|
+
const manifestName = names.has(MANIFEST_FILE)
|
|
313
|
+
? MANIFEST_FILE
|
|
314
|
+
: names.has(MANIFEST_LEGACY)
|
|
315
|
+
? MANIFEST_LEGACY
|
|
316
|
+
: null;
|
|
317
|
+
const manifest = manifestName === null
|
|
318
|
+
? null
|
|
319
|
+
: await readManifest(join(root, manifestName));
|
|
320
|
+
|
|
321
|
+
const stackList = [...stack].sort();
|
|
322
|
+
const factList = [...facts].sort();
|
|
323
|
+
return {
|
|
324
|
+
stack: stackList,
|
|
325
|
+
facts: factList,
|
|
326
|
+
// The union, pre-merged, because every consumer gates on "does this repo have
|
|
327
|
+
// this signal" and neither cares which half of the vocabulary it came from
|
|
328
|
+
// (ADR-013).
|
|
329
|
+
signals: dedupe([...stackList, ...factList]).sort(),
|
|
330
|
+
factEvidence,
|
|
331
|
+
evidence: dedupe(evidence).sort(),
|
|
332
|
+
manifest,
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
async function readManifest(path) {
|
|
337
|
+
try {
|
|
338
|
+
return JSON.parse(await readFile(path, 'utf8'));
|
|
339
|
+
} catch {
|
|
340
|
+
return { error: `${basename(path)} unparseable` };
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
async function isDir(path) {
|
|
345
|
+
try {
|
|
346
|
+
return (await stat(path)).isDirectory();
|
|
347
|
+
} catch {
|
|
348
|
+
return false;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* The paths below `root` that match a shape, in sorted order, to `GLOB_DEPTH`.
|
|
354
|
+
*
|
|
355
|
+
* The matcher is the registry's, so a fact's shape and a skill's `path:` are one
|
|
356
|
+
* language rather than two - and a shape nobody can express as a glob fails here
|
|
357
|
+
* rather than being special-cased in the probe. Dot-directories and `node_modules`
|
|
358
|
+
* are skipped for the reason the extension sample skips them: detection is not a
|
|
359
|
+
* scan of vendored output.
|
|
360
|
+
*/
|
|
361
|
+
async function findNested(root, pattern) {
|
|
362
|
+
const found = [];
|
|
363
|
+
const walk = async (dir, prefix, depth) => {
|
|
364
|
+
let items;
|
|
365
|
+
try {
|
|
366
|
+
items = await readdir(dir, { withFileTypes: true });
|
|
367
|
+
} catch {
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
for (const item of items) {
|
|
371
|
+
if (item.name.startsWith('.') || item.name === 'node_modules') continue;
|
|
372
|
+
const rel = prefix ? `${prefix}/${item.name}` : item.name;
|
|
373
|
+
if (item.isDirectory()) {
|
|
374
|
+
if (depth < GLOB_DEPTH) await walk(join(dir, item.name), rel, depth + 1);
|
|
375
|
+
} else if (matchesGlob(rel, pattern)) {
|
|
376
|
+
found.push(rel);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
};
|
|
380
|
+
await walk(root, '', 0);
|
|
381
|
+
return found.sort();
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Which extensions this repository is written in.
|
|
386
|
+
*
|
|
387
|
+
* Read from `git ls-files` where there is a repository, and by a bounded walk where
|
|
388
|
+
* there is not.
|
|
389
|
+
*
|
|
390
|
+
* The walk used to be two levels deep, on purpose, so a monorepo of thousands of files
|
|
391
|
+
* would not turn detection into a scan. The bound was the wrong shape. A language's
|
|
392
|
+
* sources sit where its build system puts them: `app/src/main/kotlin/a/b/C.kt` is six
|
|
393
|
+
* directories down, and the package segment after the source set has no maximum. A
|
|
394
|
+
* depth that reaches that file misses the same file one package deeper, which is why
|
|
395
|
+
* a depth-4 fallback cannot be the bound — the acceptance fixture is already past it.
|
|
396
|
+
* That is how a Gradle repository came to be tagged `gradle` and not `kotlin`: the
|
|
397
|
+
* sample stopped before the source set.
|
|
398
|
+
*
|
|
399
|
+
* `git ls-files --cached` is the question the sample was asking. It is the index, so an
|
|
400
|
+
* untracked file and a gitignored one are not evidence; it is every depth, because depth
|
|
401
|
+
* was never what was being asked; and the listing is sorted, so the first SAMPLE_BOUND
|
|
402
|
+
* paths are the same list on every machine. The process is stopped at that count.
|
|
403
|
+
* Buffering the whole listing and then discarding the tail is not a bound — a repository
|
|
404
|
+
* large enough to overflow the buffer would have been answered by the walk instead,
|
|
405
|
+
* which sees the files the index was asked about in order to ignore.
|
|
406
|
+
*
|
|
407
|
+
* Outside a repository there is no index. The walk is bounded by the same count, visits
|
|
408
|
+
* each directory's entries in sorted order, and skips the output directories a
|
|
409
|
+
* `.gitignore` would have skipped, because those are other people's bytes. A count gives
|
|
410
|
+
* up the tail of a tree past SAMPLE_BOUND files. A depth gives up the file the question
|
|
411
|
+
* was about.
|
|
412
|
+
*/
|
|
413
|
+
const SAMPLE_BOUND = 5000;
|
|
414
|
+
|
|
415
|
+
/** Output directories. They are not an answer to "what is this written in". */
|
|
416
|
+
const VENDORED = new Set(['node_modules', 'build', 'dist', 'out', 'target', 'vendor', 'coverage']);
|
|
417
|
+
|
|
418
|
+
async function sampleExtensions(root) {
|
|
419
|
+
const fromGit = await extensionsFromGit(root);
|
|
420
|
+
// An empty index is an answer: `[]` is not `null`. Falling through on an empty list
|
|
421
|
+
// would count the untracked files the index was consulted in order to exclude.
|
|
422
|
+
if (fromGit !== null) return fromGit;
|
|
423
|
+
return extensionsByWalk(root);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/** The suffix that selects a language, or '' when the name has none. A leading dot is not one. */
|
|
427
|
+
function extensionOf(name) {
|
|
428
|
+
const dot = name.lastIndexOf('.');
|
|
429
|
+
return dot > 0 ? name.slice(dot).toLowerCase() : '';
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* Extensions of the first SAMPLE_BOUND tracked paths under `root`.
|
|
434
|
+
* `null` when `root` is not a git work tree — the caller walks instead.
|
|
435
|
+
* @returns {Promise<string[]|null>}
|
|
436
|
+
*/
|
|
437
|
+
function extensionsFromGit(root) {
|
|
438
|
+
return new Promise((resolve) => {
|
|
439
|
+
// `-- .` keeps a fingerprint of a subdirectory from inheriting the rest of the
|
|
440
|
+
// repository. `--cached` so a file not yet added is not evidence.
|
|
441
|
+
const child = spawn('git', ['ls-files', '--cached', '-z', '--', '.'], {
|
|
442
|
+
cwd: root,
|
|
443
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
444
|
+
});
|
|
445
|
+
const found = new Set();
|
|
446
|
+
let seen = 0;
|
|
447
|
+
let buf = Buffer.alloc(0);
|
|
448
|
+
let settled = false;
|
|
449
|
+
let failed = false;
|
|
450
|
+
const finish = (value) => {
|
|
451
|
+
if (settled) return;
|
|
452
|
+
settled = true;
|
|
453
|
+
resolve(value);
|
|
454
|
+
};
|
|
455
|
+
// True once the bound is reached, having not recorded the path that crossed it.
|
|
456
|
+
const consume = (path) => {
|
|
457
|
+
if (path === '') return false;
|
|
458
|
+
seen += 1;
|
|
459
|
+
if (seen > SAMPLE_BOUND) return true;
|
|
460
|
+
const ext = extensionOf(path.slice(path.lastIndexOf('/') + 1));
|
|
461
|
+
if (ext) found.add(ext);
|
|
462
|
+
return false;
|
|
463
|
+
};
|
|
464
|
+
child.on('error', () => {
|
|
465
|
+
failed = true;
|
|
466
|
+
finish(null);
|
|
467
|
+
});
|
|
468
|
+
child.stdout.on('data', (chunk) => {
|
|
469
|
+
if (settled) return;
|
|
470
|
+
buf = Buffer.concat([buf, chunk]);
|
|
471
|
+
let nul;
|
|
472
|
+
while ((nul = buf.indexOf(0)) !== -1) {
|
|
473
|
+
const path = buf.subarray(0, nul).toString('utf8');
|
|
474
|
+
buf = buf.subarray(nul + 1);
|
|
475
|
+
if (consume(path)) {
|
|
476
|
+
child.kill();
|
|
477
|
+
finish([...found]);
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
});
|
|
482
|
+
child.on('close', (code) => {
|
|
483
|
+
if (settled) return;
|
|
484
|
+
// Non-zero is "not a repository" (or git is absent, which arrives as `error`
|
|
485
|
+
// first). Either way there is no index, and the walk answers instead.
|
|
486
|
+
if (failed || code !== 0) finish(null);
|
|
487
|
+
else finish([...found]);
|
|
488
|
+
});
|
|
489
|
+
});
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/** The no-index half. Same count as the index, because the question is the same. */
|
|
493
|
+
async function extensionsByWalk(root) {
|
|
494
|
+
const found = new Set();
|
|
495
|
+
let seen = 0;
|
|
496
|
+
const walk = async (dir) => {
|
|
497
|
+
if (seen >= SAMPLE_BOUND) return;
|
|
498
|
+
let items;
|
|
499
|
+
try {
|
|
500
|
+
items = await readdir(dir, { withFileTypes: true });
|
|
501
|
+
} catch {
|
|
502
|
+
return;
|
|
503
|
+
}
|
|
504
|
+
// `readdir` order is the filesystem's. Sorting makes the first SAMPLE_BOUND files
|
|
505
|
+
// the same files on every machine.
|
|
506
|
+
items.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
507
|
+
for (const item of items) {
|
|
508
|
+
if (seen >= SAMPLE_BOUND) return;
|
|
509
|
+
if (item.name.startsWith('.') || VENDORED.has(item.name)) continue;
|
|
510
|
+
if (item.isDirectory()) {
|
|
511
|
+
await walk(join(dir, item.name));
|
|
512
|
+
continue;
|
|
513
|
+
}
|
|
514
|
+
// A symlink is not followed: a link back at an ancestor would otherwise cycle
|
|
515
|
+
// until the count ran out, and the count would then describe the cycle.
|
|
516
|
+
if (!item.isFile()) continue;
|
|
517
|
+
seen += 1;
|
|
518
|
+
const ext = extensionOf(item.name);
|
|
519
|
+
if (ext) found.add(ext);
|
|
520
|
+
}
|
|
521
|
+
};
|
|
522
|
+
await walk(root);
|
|
523
|
+
return [...found];
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
function dedupe(list) {
|
|
527
|
+
return [...new Set(list)];
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/**
|
|
531
|
+
* Every tag `fingerprint()` can emit. The registry validator checks skill
|
|
532
|
+
* `stack` arrays against this set: a tag the detector cannot produce would match
|
|
533
|
+
* nothing, silently, forever. Keep it derived from the tables above rather than
|
|
534
|
+
* restated by hand, so adding a marker file cannot desynchronise the two.
|
|
535
|
+
*/
|
|
536
|
+
export const STACK_TAGS = new Set([
|
|
537
|
+
...MARKERS.map(([tag]) => tag),
|
|
538
|
+
...EXTENSION_TAGS.values(),
|
|
539
|
+
...DEP_TAGS.values(),
|
|
540
|
+
'wear-os', // derived from a wear/ module, not a marker file
|
|
541
|
+
]);
|
|
542
|
+
|
|
543
|
+
/** Every fact `fingerprint()` can emit, derived for the same reason as above. */
|
|
544
|
+
export const FACT_TAGS = new Set([...FACTS.map((f) => f.name), ...PKG_FACTS.map((f) => f.name)]);
|
|
545
|
+
|
|
546
|
+
/**
|
|
547
|
+
* The full vocabulary a profile's `applies` may name: platform tags and facts.
|
|
548
|
+
* ADR-013 makes these two interchangeable in a profile, so the profile loader
|
|
549
|
+
* and the registry validator both validate against this one set.
|
|
550
|
+
*/
|
|
551
|
+
export const KNOWN_TAGS = new Set([...STACK_TAGS, ...FACT_TAGS]);
|
|
552
|
+
|
|
553
|
+
export const __internals = { MARKERS, EXTENSION_TAGS, DEP_TAGS, FACTS, PKG_FACTS, SAMPLE_BOUND };
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// "Did you mean" resolution for an identifier an agent typed.
|
|
2
|
+
//
|
|
3
|
+
// A substring test is what most tools use, and it is weak exactly where it is
|
|
4
|
+
// needed: a typo in the middle of a name (`slice-commmit` for `slice-commit`)
|
|
5
|
+
// shares no substring with the right answer, so the tool says "no such thing"
|
|
6
|
+
// and the agent guesses. A bounded edit distance fixes the case that matters and
|
|
7
|
+
// stays deterministic, which substring-plus-first-letter heuristics do not.
|
|
8
|
+
//
|
|
9
|
+
// The bound matters. `coverage-hunt` is 4 edits from `add-country-galaxy`, and a
|
|
10
|
+
// suggestion that far away is noise: an agent will install the wrong skill.
|
|
11
|
+
|
|
12
|
+
/** Levenshtein distance, two-row, with an early exit once the bound is passed. */
|
|
13
|
+
export function editDistance(a, b, max = Infinity) {
|
|
14
|
+
if (a === b) return 0;
|
|
15
|
+
if (Math.abs(a.length - b.length) > max) return max + 1;
|
|
16
|
+
|
|
17
|
+
let previous = Array.from({ length: b.length + 1 }, (_, i) => i);
|
|
18
|
+
let current = new Array(b.length + 1);
|
|
19
|
+
|
|
20
|
+
for (let i = 1; i <= a.length; i += 1) {
|
|
21
|
+
current[0] = i;
|
|
22
|
+
let best = current[0];
|
|
23
|
+
for (let j = 1; j <= b.length; j += 1) {
|
|
24
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
25
|
+
current[j] = Math.min(previous[j] + 1, current[j - 1] + 1, previous[j - 1] + cost);
|
|
26
|
+
if (current[j] < best) best = current[j];
|
|
27
|
+
}
|
|
28
|
+
// Every remaining row can only increase the distance along this cell's best
|
|
29
|
+
// path, so once the whole row exceeds the bound nothing can come back under.
|
|
30
|
+
if (best > max) return max + 1;
|
|
31
|
+
const swap = previous;
|
|
32
|
+
previous = current;
|
|
33
|
+
current = swap;
|
|
34
|
+
}
|
|
35
|
+
return previous[b.length];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Candidates worth suggesting, nearest first.
|
|
40
|
+
*
|
|
41
|
+
* A substring hit is always included: `skill` is a real answer for a query of
|
|
42
|
+
* `slice-commit`, and the distance test alone would rank it below noise.
|
|
43
|
+
*/
|
|
44
|
+
export function nearMatches(needle, candidates, { max = 3, limit = 3 } = {}) {
|
|
45
|
+
if (!needle) return [];
|
|
46
|
+
const lower = needle.toLowerCase();
|
|
47
|
+
|
|
48
|
+
const scored = [];
|
|
49
|
+
for (const candidate of candidates) {
|
|
50
|
+
const cl = candidate.toLowerCase();
|
|
51
|
+
if (cl.includes(lower) || lower.includes(cl)) {
|
|
52
|
+
scored.push({ candidate, distance: 0 });
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
const distance = editDistance(lower, cl, max);
|
|
56
|
+
if (distance <= max) scored.push({ candidate, distance });
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
scored.sort((a, b) => a.distance - b.distance || a.candidate.localeCompare(b.candidate));
|
|
60
|
+
return scored.slice(0, limit).map((s) => s.candidate);
|
|
61
|
+
}
|