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,541 @@
|
|
|
1
|
+
// Registry loader and validator for graph/skills.graph.json.
|
|
2
|
+
//
|
|
3
|
+
// The registry is hand-authored, so it needs a machine check that it still
|
|
4
|
+
// describes reality. Hand-authoring 38 nodes across 6 repos is exactly the kind
|
|
5
|
+
// of bookkeeping that rots silently: a skill gets renamed, a reference file is
|
|
6
|
+
// deleted, a stack tag is invented that the detector cannot emit. `graph lint`
|
|
7
|
+
// is what keeps the registry trustworthy, and therefore what keeps resolution
|
|
8
|
+
// trustworthy - an unverified registry would make every selection it produces
|
|
9
|
+
// untrustworthy.
|
|
10
|
+
//
|
|
11
|
+
// Sizing rule: a skill's `bytes` is SKILL.md plus every reference file beneath it.
|
|
12
|
+
// Measured, never asserted. `.cursor/skills`, `.claude/skills` and `.agents/skills`
|
|
13
|
+
// are frequently symlink pairs, so the same body is reachable by two paths; sources
|
|
14
|
+
// are measured once per declared source and never double-counted.
|
|
15
|
+
|
|
16
|
+
import { readdir, readFile, stat } from 'node:fs/promises';
|
|
17
|
+
import { existsSync } from 'node:fs';
|
|
18
|
+
import { dirname, join, resolve } from 'node:path';
|
|
19
|
+
import { fileURLToPath } from 'node:url';
|
|
20
|
+
|
|
21
|
+
import { KNOWN_TAGS } from '../resolve/fingerprint.mjs';
|
|
22
|
+
import { matchesGlob, syntheticPathFor, tokenize } from '../resolve/resolve.mjs';
|
|
23
|
+
import { resolveWorkspace, WORKSPACE_REMEDY } from '../workspace.mjs';
|
|
24
|
+
|
|
25
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
26
|
+
export const REGISTRY_PATH = resolve(HERE, '..', '..', 'graph', 'skills.graph.json');
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Where sibling repos live. Sources are declared as `repo/path`, so resolving one
|
|
30
|
+
* needs a directory that CONTAINS repositories.
|
|
31
|
+
*
|
|
32
|
+
* This used to be `resolve(HERE, '..', '..', '..')`, which is correct in the checkout
|
|
33
|
+
* and wrong in `node_modules`, where it lands inside the scope directory and every
|
|
34
|
+
* source resolves to a path that cannot exist. It is now configuration - flag,
|
|
35
|
+
* environment, user config file, then a sibling heuristic that must verify - and a
|
|
36
|
+
* failure to resolve is a WARNING, not 38 `ENOENT` lines (ADR-014).
|
|
37
|
+
*
|
|
38
|
+
* Resolved once per process and memoised, because every caller needs the same answer
|
|
39
|
+
* and the answer cannot change while the process runs.
|
|
40
|
+
*/
|
|
41
|
+
let workspacePromise = null;
|
|
42
|
+
export function workspace(flags = {}) {
|
|
43
|
+
if (!workspacePromise) workspacePromise = resolveWorkspace(flags);
|
|
44
|
+
return workspacePromise;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Test seam: forget the memoised workspace so the next call re-resolves. */
|
|
48
|
+
export function resetWorkspace() {
|
|
49
|
+
workspacePromise = null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const VALID_ROLES = new Set(['canonical', 'variant', 'stub', 'duplicate', 'deprecated']);
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Load and verify the registry.
|
|
56
|
+
*
|
|
57
|
+
* `verified: false` means the structural checks passed but disk verification was SKIPPED
|
|
58
|
+
* because no workspace was available. That is a warning, not an error: the catalogue is
|
|
59
|
+
* still perfectly readable, and its byte counts simply could not be checked against
|
|
60
|
+
* files. Treating an absent workspace as a registry failure is the same mistake as
|
|
61
|
+
* printing 38 `ENOENT` lines - it converts "not configured" into "broken" (ADR-014).
|
|
62
|
+
*
|
|
63
|
+
* @returns {Promise<{ok: boolean, registry: object|null, errors: string[], warnings: string[],
|
|
64
|
+
* verified: boolean, workspace: {root: string|null, source: string, detail: string}>}
|
|
65
|
+
*/
|
|
66
|
+
export async function loadRegistry(path = REGISTRY_PATH, flags = {}) {
|
|
67
|
+
let registry;
|
|
68
|
+
try {
|
|
69
|
+
registry = JSON.parse(await readFile(path, 'utf8'));
|
|
70
|
+
} catch (error) {
|
|
71
|
+
return {
|
|
72
|
+
ok: false,
|
|
73
|
+
registry: null,
|
|
74
|
+
errors: [`${path}: unreadable or invalid JSON (${error.message})`],
|
|
75
|
+
warnings: [],
|
|
76
|
+
verified: false,
|
|
77
|
+
workspace: { root: null, source: 'none', detail: 'not resolved' },
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const errors = [];
|
|
82
|
+
const warnings = [];
|
|
83
|
+
const lint = lintRegistry(registry);
|
|
84
|
+
|
|
85
|
+
for (const error of lint.sync) errors.push(error);
|
|
86
|
+
for (const warning of lint.warnings) warnings.push(warning);
|
|
87
|
+
|
|
88
|
+
const ws = await workspace(flags);
|
|
89
|
+
|
|
90
|
+
if (ws.error) {
|
|
91
|
+
// A config file that exists and does not parse is a real mistake, and it is the
|
|
92
|
+
// author's to fix - so this one IS an error. An absent config is not.
|
|
93
|
+
errors.push(`${ws.detail}: ${ws.error}`);
|
|
94
|
+
} else if (ws.root === null) {
|
|
95
|
+
warnings.push(
|
|
96
|
+
`skill sources are not available: no workspace configured, so byte counts were not verified`,
|
|
97
|
+
);
|
|
98
|
+
warnings.push(WORKSPACE_REMEDY);
|
|
99
|
+
} else if (!existsSync(ws.root)) {
|
|
100
|
+
// A workspace that was CONFIGURED but is not there is a different fact from one
|
|
101
|
+
// that was never configured: the reader named a directory that has moved. Naming
|
|
102
|
+
// it is useful; turning it into 38 ENOENT lines is not, and it is the same
|
|
103
|
+
// mistake as above with an extra cause (ADR-014).
|
|
104
|
+
warnings.push(
|
|
105
|
+
`skill sources were not verified: the configured workspace ${ws.root} (from ${ws.detail}) does not exist`,
|
|
106
|
+
);
|
|
107
|
+
} else if (!holdsAnyRepo(registry, ws.root)) {
|
|
108
|
+
// Exists, but holds none of the directories the registry names. Either the
|
|
109
|
+
// reader pointed at the wrong directory or the repos are not cloned there. Both
|
|
110
|
+
// are one fact with one remedy, and neither is 38 ENOENT lines.
|
|
111
|
+
const named = [...new Set((registry.skills ?? []).flatMap((s) => (s.sources ?? []).map((x) => x.repo)))].sort();
|
|
112
|
+
warnings.push(
|
|
113
|
+
`skill sources were not verified: ${ws.root} (from ${ws.detail}) contains none of the ` +
|
|
114
|
+
`repositories the registry names (${named.slice(0, 4).join(', ')}${named.length > 4 ? ', …' : ''})`,
|
|
115
|
+
);
|
|
116
|
+
} else {
|
|
117
|
+
// Disk verification is async: a registry that no longer matches the repos is
|
|
118
|
+
// worse than one that is merely malformed, because it still resolves.
|
|
119
|
+
const disk = await lintSourcesOnDisk(registry, ws.root);
|
|
120
|
+
errors.push(...disk.errors);
|
|
121
|
+
warnings.push(...disk.warnings);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const verified =
|
|
125
|
+
ws.error === null && ws.root !== null && existsSync(ws.root) && holdsAnyRepo(registry, ws.root);
|
|
126
|
+
|
|
127
|
+
return {
|
|
128
|
+
ok: errors.length === 0,
|
|
129
|
+
registry,
|
|
130
|
+
errors,
|
|
131
|
+
warnings,
|
|
132
|
+
verified,
|
|
133
|
+
workspace: { root: ws.root, source: ws.source, detail: ws.detail },
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Does this directory hold any of the repositories the registry names?
|
|
139
|
+
*
|
|
140
|
+
* Existence is not enough. Pointing `--workspace` at the wrong directory - a home
|
|
141
|
+
* folder, an empty tmpdir, the wrong checkout - produces 38 ENOENTs that read as a
|
|
142
|
+
* broken registry rather than a wrong pointer, which is exactly backwards.
|
|
143
|
+
*/
|
|
144
|
+
function holdsAnyRepo(registry, root) {
|
|
145
|
+
const named = new Set(
|
|
146
|
+
(registry.skills ?? []).flatMap((s) => (s.sources ?? []).map((x) => x.repo)).filter(Boolean),
|
|
147
|
+
);
|
|
148
|
+
for (const repo of named) {
|
|
149
|
+
if (existsSync(join(root, repo))) return true;
|
|
150
|
+
}
|
|
151
|
+
return false;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Pure structural checks. No filesystem access. */
|
|
155
|
+
export function lintRegistry(registry) {
|
|
156
|
+
const errors = [];
|
|
157
|
+
const warnings = [];
|
|
158
|
+
const err = (message) => errors.push(message);
|
|
159
|
+
const warn = (message) => warnings.push(message);
|
|
160
|
+
|
|
161
|
+
const capabilities = new Map((registry.capabilities ?? []).map((c) => [c.id, c]));
|
|
162
|
+
const skills = registry.skills ?? [];
|
|
163
|
+
const index = new Map();
|
|
164
|
+
const threshold = registry.deferredThreshold ?? 20000;
|
|
165
|
+
|
|
166
|
+
// --- shape -----------------------------------------------------------------
|
|
167
|
+
if (!Array.isArray(registry.capabilities) || capabilities.size === 0) {
|
|
168
|
+
err('capabilities: missing or empty');
|
|
169
|
+
}
|
|
170
|
+
if (!Array.isArray(registry.skills) || skills.length === 0) {
|
|
171
|
+
err('skills: missing or empty');
|
|
172
|
+
return { sync: errors, warnings, index };
|
|
173
|
+
}
|
|
174
|
+
for (const c of registry.capabilities) {
|
|
175
|
+
if (!c.id || !c.summary) err(`capability ${c.id ?? '<no id>'}: needs id and summary`);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// --- duplicate names -------------------------------------------------------
|
|
179
|
+
for (const skill of skills) {
|
|
180
|
+
if (index.has(skill.name)) {
|
|
181
|
+
err(`duplicate skill name: ${skill.name}`);
|
|
182
|
+
} else {
|
|
183
|
+
index.set(skill.name, skill);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// --- per-skill checks ------------------------------------------------------
|
|
188
|
+
for (const skill of skills) {
|
|
189
|
+
const at = skill.name ?? '<unnamed>';
|
|
190
|
+
|
|
191
|
+
if (!skill.summary) err(`${at}: missing summary`);
|
|
192
|
+
if (!Array.isArray(skill.capabilities) || skill.capabilities.length === 0) {
|
|
193
|
+
err(`${at}: needs at least one capability`);
|
|
194
|
+
}
|
|
195
|
+
for (const cap of skill.capabilities ?? []) {
|
|
196
|
+
if (!capabilities.has(cap)) err(`${at}: unknown capability "${cap}"`);
|
|
197
|
+
}
|
|
198
|
+
if (!Array.isArray(skill.stack) || skill.stack.length === 0) {
|
|
199
|
+
err(`${at}: needs a stack array (an empty one can never be selected)`);
|
|
200
|
+
}
|
|
201
|
+
if (!Array.isArray(skill.paths)) warn(`${at}: no paths, so it can only be found by keyword or capability`);
|
|
202
|
+
if (!Array.isArray(skill.keywords) || skill.keywords.length === 0) {
|
|
203
|
+
err(`${at}: needs keywords, otherwise it is unreachable from a task description`);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
checkStack(at, skill.stack, err);
|
|
207
|
+
checkGlobs(at, skill.paths, err);
|
|
208
|
+
checkKeywords(at, skill.keywords, err);
|
|
209
|
+
|
|
210
|
+
// `deferred` is derived. A hand-edited `deferred: false` on a 384 KB skill
|
|
211
|
+
// would let the whole Cloudflare reference land in a context window, which is
|
|
212
|
+
// the exact failure this tool exists to prevent.
|
|
213
|
+
const shouldDefer = skill.bytes > threshold;
|
|
214
|
+
if (shouldDefer && skill.deferred !== true) {
|
|
215
|
+
err(`${at}: ${skill.bytes} bytes exceeds deferredThreshold ${threshold} but deferred is not true`);
|
|
216
|
+
}
|
|
217
|
+
if (!shouldDefer && skill.deferred === true) {
|
|
218
|
+
warn(`${at}: deferred is true but ${skill.bytes} bytes is under the threshold ${threshold}`);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
checkRequires(at, skill, index, err);
|
|
222
|
+
checkAlias(at, skill, index, err);
|
|
223
|
+
checkVariants(at, skill, index, err);
|
|
224
|
+
checkSources(at, skill, err, warn);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// --- capability coverage ---------------------------------------------------
|
|
228
|
+
const covered = new Set(skills.flatMap((s) => s.capabilities ?? []));
|
|
229
|
+
for (const cap of capabilities.keys()) {
|
|
230
|
+
if (!covered.has(cap)) err(`capability "${cap}" has no covering skill (orphan)`);
|
|
231
|
+
}
|
|
232
|
+
for (const cap of covered) {
|
|
233
|
+
if (!capabilities.has(cap)) err(`capability "${cap}" is referenced but not declared`);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// --- sibling groups --------------------------------------------------------
|
|
237
|
+
const groups = registry.siblingGroups ?? [];
|
|
238
|
+
const groupNames = new Set();
|
|
239
|
+
for (const group of groups) {
|
|
240
|
+
if (groupNames.has(group.name)) err(`duplicate siblingGroup "${group.name}"`);
|
|
241
|
+
groupNames.add(group.name);
|
|
242
|
+
if (!capabilities.has(group.capability)) {
|
|
243
|
+
err(`siblingGroup "${group.name}": unknown capability "${group.capability}"`);
|
|
244
|
+
}
|
|
245
|
+
if (group.discriminator !== 'stack') {
|
|
246
|
+
err(`siblingGroup "${group.name}": discriminator must be "stack" (only stack is deterministic)`);
|
|
247
|
+
}
|
|
248
|
+
const memberStacks = new Set();
|
|
249
|
+
for (const member of group.members ?? []) {
|
|
250
|
+
const skill = index.get(member);
|
|
251
|
+
if (!skill) {
|
|
252
|
+
err(`siblingGroup "${group.name}": unknown member "${member}"`);
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
if (skill.variantOf !== group.name) {
|
|
256
|
+
err(`siblingGroup "${group.name}": member "${member}" does not declare variantOf "${group.name}"`);
|
|
257
|
+
}
|
|
258
|
+
memberStacks.add(skill.stack.join(','));
|
|
259
|
+
}
|
|
260
|
+
// Siblings that resolve identically defeat the purpose of having siblings.
|
|
261
|
+
if (memberStacks.size < (group.members ?? []).length && (group.members ?? []).length > 1) {
|
|
262
|
+
err(`siblingGroup "${group.name}": members must be discriminated by stack, but two share an identical stack`);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
for (const skill of skills) {
|
|
266
|
+
if (skill.variantOf && !groupNames.has(skill.variantOf)) {
|
|
267
|
+
err(`${skill.name}: variantOf "${skill.variantOf}" has no siblingGroup entry`);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
return { sync: errors, warnings, index };
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* A declared keyword has to be sayable.
|
|
276
|
+
*
|
|
277
|
+
* The existing check on this field is `needs keywords`, which is satisfied by a list of
|
|
278
|
+
* words no task can produce. A keyword is only reachable if the tokenizer keeps at least
|
|
279
|
+
* one of its words, so that is what this asks - the same question `checkGlobs` asks of a
|
|
280
|
+
* path, and for the same reason: 13 of 232 unique keywords were unreachable and nothing
|
|
281
|
+
* said so. Multi-word keywords are reachable as phrases, so only a keyword whose words
|
|
282
|
+
* are ALL dropped fails here.
|
|
283
|
+
*/
|
|
284
|
+
function checkKeywords(at, keywords, err) {
|
|
285
|
+
for (const keyword of keywords ?? []) {
|
|
286
|
+
if (tokenize(keyword).length === 0) {
|
|
287
|
+
err(`${at}: keyword "${keyword}" cannot be reached: the tokenizer keeps no word in it`);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* A declared glob has to match a path built from itself.
|
|
294
|
+
*
|
|
295
|
+
* The other per-skill checks ask whether a value is present and well-formed. This one
|
|
296
|
+
* asks whether it does anything, which is the only question the resolver actually
|
|
297
|
+
* cares about: a glob that matches no path can never select its skill, and it costs
|
|
298
|
+
* nothing to declare, so only a check that compiles it catches one. 13 of 65 registry
|
|
299
|
+
* globs matched nothing until this existed (S02).
|
|
300
|
+
*/
|
|
301
|
+
function checkGlobs(at, paths, err) {
|
|
302
|
+
for (const glob of paths ?? []) {
|
|
303
|
+
const candidates = syntheticPathFor(glob);
|
|
304
|
+
if (candidates.length === 0) {
|
|
305
|
+
err(`${at}: path glob "${glob}" expands to no path at all`);
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
308
|
+
const missed = candidates.filter((p) => !matchesGlob(p, glob));
|
|
309
|
+
if (missed.length > 0) {
|
|
310
|
+
err(`${at}: path glob "${glob}" does not match ${missed.join(', ')}`);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function checkStack(at, stack, err) {
|
|
316
|
+
for (const tag of stack ?? []) {
|
|
317
|
+
if (!KNOWN_TAGS.has(tag)) {
|
|
318
|
+
// A tag the detector cannot emit matches nothing, forever, with no error at
|
|
319
|
+
// resolution time. This is the check that turns a silent dead entry into a
|
|
320
|
+
// loud one. The vocabulary is stack tags plus emitted facts (ADR-013).
|
|
321
|
+
err(`${at}: tag "${tag}" is not emitted by fingerprint() (known: ${[...KNOWN_TAGS].sort().join(', ')})`);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
for (const duplicate of duplicates(stack)) {
|
|
325
|
+
err(`${at}: duplicate stack tag "${duplicate}"`);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function checkRequires(at, skill, index, err) {
|
|
330
|
+
for (const dep of skill.requires ?? []) {
|
|
331
|
+
if (!index.has(dep)) err(`${at}: requires unknown skill "${dep}"`);
|
|
332
|
+
if (dep === skill.name) err(`${at}: requires itself`);
|
|
333
|
+
}
|
|
334
|
+
// Cycle detection over `requires`. Depth is bounded by node count, so an
|
|
335
|
+
// accidental cycle terminates rather than hanging the lint.
|
|
336
|
+
const seen = new Set([skill.name]);
|
|
337
|
+
let frontier = skill.requires ?? [];
|
|
338
|
+
while (frontier.length > 0) {
|
|
339
|
+
const next = [];
|
|
340
|
+
for (const dep of frontier) {
|
|
341
|
+
if (seen.has(dep)) {
|
|
342
|
+
err(`requires cycle: ${[...seen].join(' -> ')} -> ${dep}`);
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
seen.add(dep);
|
|
346
|
+
next.push(...(index.get(dep)?.requires ?? []));
|
|
347
|
+
}
|
|
348
|
+
frontier = next;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function checkAlias(at, skill, index, err) {
|
|
353
|
+
if (!skill.aliasOf) return;
|
|
354
|
+
const target = index.get(skill.aliasOf);
|
|
355
|
+
if (!target) {
|
|
356
|
+
err(`${at}: aliasOf unknown skill "${skill.aliasOf}"`);
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
if (target.aliasOf) {
|
|
360
|
+
// A retired name aliasing another retired name is a chain nobody resolves.
|
|
361
|
+
err(`${at}: alias-of-alias, ${at} -> ${target.name} -> ${target.aliasOf}; collapse into one hop`);
|
|
362
|
+
}
|
|
363
|
+
if ((target.requires ?? []).includes(skill.name) || (skill.requires ?? []).includes(target.name)) {
|
|
364
|
+
err(`${at}: alias target ${target.name} and the alias require each other`);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function checkVariants(at, skill, index, err) {
|
|
369
|
+
const variants = skill.variants ?? [];
|
|
370
|
+
if (variants.length === 0) return;
|
|
371
|
+
|
|
372
|
+
const ids = new Set();
|
|
373
|
+
const parentStack = new Set(skill.stack ?? []);
|
|
374
|
+
for (const variant of variants) {
|
|
375
|
+
if (!variant.id) err(`${at}: a variant is missing its id`);
|
|
376
|
+
if (ids.has(variant.id)) err(`${at}: duplicate variant id "${variant.id}"`);
|
|
377
|
+
ids.add(variant.id);
|
|
378
|
+
|
|
379
|
+
checkStack(`${at}#${variant.id}`, variant.stack, err);
|
|
380
|
+
|
|
381
|
+
// A variant the parent does not admit could still be selected, because the
|
|
382
|
+
// parent stack is what gates the node.
|
|
383
|
+
for (const tag of variant.stack ?? []) {
|
|
384
|
+
if (!parentStack.has(tag)) {
|
|
385
|
+
err(`${at}#${variant.id}: stack tag "${tag}" is not in the parent node's stack`);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
if (typeof variant.bytes !== 'number') {
|
|
389
|
+
err(`${at}#${variant.id}: missing bytes`);
|
|
390
|
+
} else if (variant.bytes > (skill.bytes ?? 0)) {
|
|
391
|
+
err(`${at}#${variant.id}: bytes ${variant.bytes} exceeds the node total ${skill.bytes}`);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
if (variants.length > 1) {
|
|
395
|
+
const signatures = new Set(variants.map((v) => (v.stack ?? []).join(',')));
|
|
396
|
+
if (signatures.size < variants.length) {
|
|
397
|
+
err(`${at}: variants are not discriminated by stack (two have an identical stack)`);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function checkSources(at, skill, err, warn) {
|
|
403
|
+
const sources = skill.sources ?? [];
|
|
404
|
+
if (sources.length === 0) {
|
|
405
|
+
// The registry is a description of reality; a skill with no source is a
|
|
406
|
+
// claim with nothing behind it.
|
|
407
|
+
err(`${at}: no sources; every skill must point at the file it came from`);
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
// Validate the shape of every source before interpreting any of them, so a
|
|
411
|
+
// typo produces one actionable error instead of a second confusing complaint.
|
|
412
|
+
let shapeOk = true;
|
|
413
|
+
for (const source of sources) {
|
|
414
|
+
if (!VALID_ROLES.has(source.role)) {
|
|
415
|
+
err(`${at}: source ${source.repo}/${source.path} has invalid role "${source.role}" (expected one of ${[...VALID_ROLES].join(', ')})`);
|
|
416
|
+
shapeOk = false;
|
|
417
|
+
}
|
|
418
|
+
if (!source.repo || !source.path) {
|
|
419
|
+
err(`${at}: a source is missing repo or path`);
|
|
420
|
+
shapeOk = false;
|
|
421
|
+
}
|
|
422
|
+
if (typeof source.bytes !== 'number') {
|
|
423
|
+
err(`${at}: source ${source.repo}/${source.path} is missing bytes`);
|
|
424
|
+
shapeOk = false;
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
if (!shapeOk) return;
|
|
428
|
+
// Reject unknown roles before counting them, so a typo produces one actionable
|
|
429
|
+
// error instead of a second confusing complaint about the canonical count.
|
|
430
|
+
const roleNames = sources.map((s) => s.role).filter((r) => VALID_ROLES.has(r));
|
|
431
|
+
if (roleNames.length !== sources.length) return;
|
|
432
|
+
|
|
433
|
+
const canonical = sources.filter((s) => s.role === 'canonical');
|
|
434
|
+
if (canonical.length > 1) {
|
|
435
|
+
err(`${at}: expected at most 1 canonical source, found ${canonical.length}`);
|
|
436
|
+
}
|
|
437
|
+
// A retired alias has no canonical source of its own: it delegates to its
|
|
438
|
+
// target. Requiring one would force a fake canonical entry onto a node whose
|
|
439
|
+
// whole purpose is to point elsewhere. What it must have instead is at least
|
|
440
|
+
// one source, so the alias is traceable to the file it was retired from.
|
|
441
|
+
if (canonical.length === 0 && !skill.aliasOf) {
|
|
442
|
+
err(`${at}: no canonical source; exactly one is required unless the node is an aliasOf another skill`);
|
|
443
|
+
}
|
|
444
|
+
for (const duplicate of duplicates(sources.map((s) => `${s.repo}/${s.path}`))) {
|
|
445
|
+
warn(`${at}: declares the same source path twice (${duplicate})`);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/** Compare declared sizes against the filesystem. */
|
|
450
|
+
async function lintSourcesOnDisk(registry, workspaceRoot) {
|
|
451
|
+
const errors = [];
|
|
452
|
+
const warnings = [];
|
|
453
|
+
|
|
454
|
+
for (const skill of registry.skills ?? []) {
|
|
455
|
+
for (const source of skill.sources ?? []) {
|
|
456
|
+
const at = `${skill.name}: ${source.repo}/${source.path}`;
|
|
457
|
+
if (!source.repo || !source.path) continue;
|
|
458
|
+
const full = join(workspaceRoot, source.repo, source.path);
|
|
459
|
+
|
|
460
|
+
let measured;
|
|
461
|
+
try {
|
|
462
|
+
measured = await measureSkill(full);
|
|
463
|
+
} catch (error) {
|
|
464
|
+
errors.push(`${at}: ${error.code ?? error.message} (registry points at a path that does not exist)`);
|
|
465
|
+
continue;
|
|
466
|
+
}
|
|
467
|
+
if (typeof source.bytes === 'number' && source.bytes !== measured.bytes) {
|
|
468
|
+
errors.push(
|
|
469
|
+
`${at}: declared ${source.bytes} bytes, on disk ${measured.bytes} (${measured.delta > 0 ? '+' : ''}${measured.delta})`,
|
|
470
|
+
);
|
|
471
|
+
}
|
|
472
|
+
if (!source.bytes) warnings.push(`${at}: no declared bytes to compare against`);
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
return { errors, warnings };
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* SKILL.md plus every file beneath the skill directory.
|
|
480
|
+
* Handles both layouts: a directory containing SKILL.md, and the flat
|
|
481
|
+
* `skills/<name>.md` form one repository still uses (ADR-009 says that will be
|
|
482
|
+
* wrapped, so lint reports it rather than accepting it silently).
|
|
483
|
+
*/
|
|
484
|
+
export async function measureSkill(full) {
|
|
485
|
+
const info = await stat(full);
|
|
486
|
+
|
|
487
|
+
if (info.isFile()) {
|
|
488
|
+
const bytes = (await readFile(full)).byteLength;
|
|
489
|
+
return { bytes, files: 1, flat: true };
|
|
490
|
+
}
|
|
491
|
+
if (!info.isDirectory()) {
|
|
492
|
+
throw Object.assign(new Error(`${full} is neither a file nor a directory`), { code: 'NOT_A_SKILL' });
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
let bytes = 0;
|
|
496
|
+
let files = 0;
|
|
497
|
+
const walk = async (dir) => {
|
|
498
|
+
for (const entry of await readdir(dir, { withFileTypes: true })) {
|
|
499
|
+
const child = join(dir, entry.name);
|
|
500
|
+
if (entry.isDirectory()) {
|
|
501
|
+
await walk(child);
|
|
502
|
+
continue;
|
|
503
|
+
}
|
|
504
|
+
const data = await readFile(child);
|
|
505
|
+
bytes += data.byteLength;
|
|
506
|
+
files += 1;
|
|
507
|
+
}
|
|
508
|
+
};
|
|
509
|
+
await walk(full);
|
|
510
|
+
|
|
511
|
+
// Confirm the entry point exists, because a directory of reference files
|
|
512
|
+
// without SKILL.md is not a skill.
|
|
513
|
+
//
|
|
514
|
+
// `flat` means one thing only: this entry has no SKILL.md to read. It is NOT
|
|
515
|
+
// "has a .md file sitting beside SKILL.md" - a `checklist.md` reference in the
|
|
516
|
+
// same directory is a reference, and treating it as a flat layout made install
|
|
517
|
+
// try to readFile() a directory.
|
|
518
|
+
let hasEntry = false;
|
|
519
|
+
try {
|
|
520
|
+
await stat(join(full, 'SKILL.md'));
|
|
521
|
+
hasEntry = true;
|
|
522
|
+
} catch {
|
|
523
|
+
hasEntry = false;
|
|
524
|
+
}
|
|
525
|
+
if (!hasEntry) {
|
|
526
|
+
const md = files === 1 ? '' : '';
|
|
527
|
+
throw Object.assign(new Error(`${full} has no SKILL.md entry point${md}`), { code: 'NO_SKILL_MD' });
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
return { bytes, files, flat: false };
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
function duplicates(list) {
|
|
534
|
+
const seen = new Set();
|
|
535
|
+
const dupes = new Set();
|
|
536
|
+
for (const item of list) {
|
|
537
|
+
if (seen.has(item)) dupes.add(item);
|
|
538
|
+
seen.add(item);
|
|
539
|
+
}
|
|
540
|
+
return [...dupes];
|
|
541
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// What a repository can demonstrably run, as opposed to what its stack suggests.
|
|
2
|
+
//
|
|
3
|
+
// This exists because the first real adoption declared five gates and could run two.
|
|
4
|
+
// The stack table proposes a formatter, a linter and an i18n check for any Node repo;
|
|
5
|
+
// a repository with no prettier installed, no `lint` script and no declared command
|
|
6
|
+
// for the i18n check. Every one of those gates fails closed by design, so installing the
|
|
7
|
+
// dispatcher on that manifest blocks every commit and teaches the team to reach for
|
|
8
|
+
// `--no-verify`. The dispatcher was right; the declaration was wrong.
|
|
9
|
+
//
|
|
10
|
+
// Three kinds of evidence, and they are not interchangeable:
|
|
11
|
+
// - a script that EXISTS in package.json, which is what `npm run <name>` will attempt
|
|
12
|
+
// - a binary INSTALLED in node_modules/.bin, which is what the staged formatter shells
|
|
13
|
+
// out to; a declared dependency with no install is not one
|
|
14
|
+
// - a command the manifest itself declares under `gates.commands`, because "repo
|
|
15
|
+
// specific check" means the repo names the program
|
|
16
|
+
//
|
|
17
|
+
// Deliberately no inference. A repo with `lint` in its dependencies and no `lint` script
|
|
18
|
+
// cannot run a `lint` gate, and this reads the manifest rather than the dependency graph
|
|
19
|
+
// so the two can never disagree about what a script does.
|
|
20
|
+
|
|
21
|
+
import { readdir, readFile, stat } from 'node:fs/promises';
|
|
22
|
+
import { join } from 'node:path';
|
|
23
|
+
import { manifestPath } from '../projection/manifest.mjs';
|
|
24
|
+
|
|
25
|
+
/** Binaries the shared dispatcher shells out to directly, rather than via a script. */
|
|
26
|
+
const BIN_DIR = join('node_modules', '.bin');
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Non-npm runners, and the file that proves one is in use.
|
|
30
|
+
*
|
|
31
|
+
* Only enough to answer "is `npm run <script>` even a sentence this repo can finish?".
|
|
32
|
+
* The concrete Gradle task names are deliberately NOT listed: guessing them would print
|
|
33
|
+
* a remedy that is wrong in exactly the repos where it is printed, which is the failure
|
|
34
|
+
* this whole module exists to remove.
|
|
35
|
+
*/
|
|
36
|
+
const RUNNER_MARKERS = {
|
|
37
|
+
gradle: 'gradlew',
|
|
38
|
+
python: 'pyproject.toml',
|
|
39
|
+
bun: 'bun.lockb',
|
|
40
|
+
cargo: 'Cargo.toml',
|
|
41
|
+
go: 'go.mod',
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Does this path exist as a FILE?
|
|
46
|
+
*
|
|
47
|
+
* `readdir` was the obvious call here and it silently answers "no" for every file —
|
|
48
|
+
* `ENOTDIR` — which would have left every Gradle, Cargo, Go and Bun repo classified as
|
|
49
|
+
* having no runner at all. A wrong answer from an evidence reader is worse than none,
|
|
50
|
+
* because the caller cannot tell the difference.
|
|
51
|
+
*/
|
|
52
|
+
async function fileExists(path) {
|
|
53
|
+
try {
|
|
54
|
+
return (await stat(path)).isFile();
|
|
55
|
+
} catch {
|
|
56
|
+
// Anything unreadable is not evidence either way, and treating it as absent keeps a
|
|
57
|
+
// gate out rather than declaring one this repo cannot prove.
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Read what this repo can run.
|
|
64
|
+
*
|
|
65
|
+
* @param {string} root
|
|
66
|
+
* @returns {Promise<{scripts: Record<string,string>, binaries: string[], commands: Record<string,string>, runner: string, error: string|null}>}
|
|
67
|
+
*/
|
|
68
|
+
export async function readCapability(root) {
|
|
69
|
+
const capability = { scripts: {}, binaries: [], commands: {}, runner: null, error: null };
|
|
70
|
+
|
|
71
|
+
try {
|
|
72
|
+
const manifest = JSON.parse(await readFile(join(root, 'package.json'), 'utf8'));
|
|
73
|
+
capability.scripts = manifest.scripts ?? {};
|
|
74
|
+
capability.runner = 'npm';
|
|
75
|
+
} catch (error) {
|
|
76
|
+
// A repo with no manifest is a normal shape — a Python or Gradle repo has no
|
|
77
|
+
// package.json at all — so this is reported, never thrown. The gate filter treats
|
|
78
|
+
// an empty script map as "no npm script gates", which is the truth.
|
|
79
|
+
capability.error = error.code === 'ENOENT' ? null : `package.json: ${error.message}`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// `runner` records whether `npm run` is even the language a gate would be written in.
|
|
83
|
+
// Without it, a dropped `test` gate on a Gradle repo is explained as a missing
|
|
84
|
+
// package.json script — a fix the reader cannot make, in a file that will never exist
|
|
85
|
+
// in that repo. A remedy naming an action the reader cannot take is a second problem
|
|
86
|
+
// stacked on the first, so the runner decides which remedy is even applicable.
|
|
87
|
+
//
|
|
88
|
+
// `'none'` is a real answer, distinct from "not reported": a caller that builds a
|
|
89
|
+
// capability by hand may leave `runner` unset, and treating that as "no package.json"
|
|
90
|
+
// would make an npm repo's remedy claim a file it has.
|
|
91
|
+
if (capability.runner === null) {
|
|
92
|
+
capability.runner = 'none';
|
|
93
|
+
for (const [tag, marker] of Object.entries(RUNNER_MARKERS)) {
|
|
94
|
+
if (await fileExists(join(root, marker))) {
|
|
95
|
+
capability.runner = tag;
|
|
96
|
+
break;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
try {
|
|
102
|
+
capability.binaries = (await readdir(join(root, BIN_DIR))).filter((n) => !n.includes('.'));
|
|
103
|
+
} catch {
|
|
104
|
+
// No node_modules is the same as no installed binaries.
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// `gates.commands` lives in simt-dev.json, NOT package.json, so it has to be read
|
|
108
|
+
// here or a command gate looks undeclared to `check` while the dispatcher runs it
|
|
109
|
+
// successfully — the gate and the checker disagreeing about the same manifest.
|
|
110
|
+
//
|
|
111
|
+
// This is the one capability source that is a declaration rather than an observation,
|
|
112
|
+
// and that asymmetry is the point: a command gate is the repo naming its own program,
|
|
113
|
+
// so the only evidence that can prove it is the declaration itself. A repo with no
|
|
114
|
+
// manifest simply has no declared commands.
|
|
115
|
+
try {
|
|
116
|
+
const manifest = JSON.parse(await readFile(await manifestPath(root), 'utf8'));
|
|
117
|
+
capability.commands = declaredCommands(manifest);
|
|
118
|
+
} catch {
|
|
119
|
+
capability.commands = {};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return capability;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** The declared `gates.commands` map from an existing manifest, if it has one. */
|
|
126
|
+
export function declaredCommands(manifest) {
|
|
127
|
+
const commands = manifest?.gates?.commands;
|
|
128
|
+
return commands && typeof commands === 'object' ? commands : {};
|
|
129
|
+
}
|