@tapcue/extension-sdk 0.1.1 → 0.2.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tapcue/extension-sdk",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "The Tapcue extension API: types, helpers, and the manifest validator that is the executable spec",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -0,0 +1,69 @@
1
+ /**
2
+ * The words a keyword may not be (spec 028 §1.4).
3
+ *
4
+ * A `keywords` entry buys its command a whole-word match in root search. That is the right trade
5
+ * for `eyedropper` and a disaster for `open`: one extension claiming the ten commonest verbs in
6
+ * five languages would sit in the candidate pool of nearly every query anybody types, and the only
7
+ * thing standing between it and the top of the list would be the ranker's opinion of a tie.
8
+ *
9
+ * So the blacklist is not a style guide, it is the thing that keeps the keyword field worth
10
+ * having. It is deliberately short and deliberately multilingual — an author writing a Chinese
11
+ * extension should hit exactly the same wall as one writing an English one, and a blacklist that
12
+ * only knew English would read as "generic words are fine as long as they are not in English".
13
+ *
14
+ * **What it is not**: a stopword list for the ranker. The ranker sees the user's query and the
15
+ * candidate's real title; this is only about what an author may *add* to that. A command called
16
+ * "Open in Browser" keeps that title, and keeps matching `open`, because its title says so.
17
+ *
18
+ * Matching is on the normalized keyword (lowercased, whitespace-trimmed), whole-string. `opener`
19
+ * and `open file` are not on the list: the entry has to *be* the generic word for the failure mode
20
+ * above to apply.
21
+ */
22
+
23
+ /**
24
+ * One family per row, so a reviewer can see what is being refused and why.
25
+ *
26
+ * The families are the ones spec §1.4 names — the verbs and nouns every launcher command shares —
27
+ * in the languages Tapcue itself ships (`locales/`): English, Simplified Chinese, Japanese,
28
+ * German, French, Spanish.
29
+ */
30
+ const GENERIC_KEYWORD_FAMILIES: Readonly<Record<string, readonly string[]>> = Object.freeze({
31
+ open: ["open", "打开", "开启", "開く", "ひらく", "öffnen", "offnen", "ouvrir", "abrir"],
32
+ file: ["file", "files", "文件", "檔案", "ファイル", "datei", "dateien", "fichier", "fichiers", "archivo", "archivos"],
33
+ search: [
34
+ "search", "find", "查找", "搜索", "搜寻", "検索", "さがす", "suche", "suchen", "finden",
35
+ "rechercher", "recherche", "chercher", "buscar", "búsqueda", "busqueda",
36
+ ],
37
+ new: ["new", "create", "新建", "新增", "创建", "新規", "あたらしい", "neu", "erstellen", "nouveau", "nouvelle", "créer", "creer", "nuevo", "nueva", "crear"],
38
+ copy: ["copy", "复制", "拷贝", "コピー", "kopieren", "kopie", "copier", "copiar"],
39
+ paste: ["paste", "粘贴", "貼り付け", "はりつけ", "einfügen", "einfugen", "coller", "pegar"],
40
+ app: ["app", "apps", "application", "applications", "应用", "程序", "アプリ", "アプリケーション", "anwendung", "programm", "aplicación", "aplicacion", "aplicaciones"],
41
+ run: ["run", "execute", "start", "运行", "执行", "启动", "実行", "じっこう", "ausführen", "ausfuhren", "starten", "exécuter", "executer", "lancer", "ejecutar", "iniciar"],
42
+ show: ["show", "view", "display", "显示", "查看", "表示", "ひょうじ", "zeigen", "anzeigen", "afficher", "voir", "mostrar", "ver"],
43
+ go: ["go", "goto", "前往", "跳转", "移動", "いく", "gehe", "gehen", "aller", "ir"],
44
+ });
45
+
46
+ /**
47
+ * Every blacklisted word, flattened. Exported so the Swift mirror can read one list rather than
48
+ * reconstructing the families, and so a test can assert the two agree.
49
+ */
50
+ export const GENERIC_KEYWORDS: readonly string[] = Object.freeze(
51
+ [...new Set(Object.values(GENERIC_KEYWORD_FAMILIES).flat())].sort(),
52
+ );
53
+
54
+ const GENERIC_KEYWORD_SET: ReadonlySet<string> = new Set(GENERIC_KEYWORDS);
55
+
56
+ /**
57
+ * Normalizes the way the check compares: case-folded, whitespace-trimmed, inner runs collapsed.
58
+ *
59
+ * Full-width Latin (`open`) folds to ASCII through NFKC, because an author who types a keyword
60
+ * in a CJK input method should not get a different verdict than one who did not.
61
+ */
62
+ export function normalizeKeyword(value: string): string {
63
+ return value.normalize("NFKC").trim().replace(/\s+/g, " ").toLowerCase();
64
+ }
65
+
66
+ /** Whether this keyword is one of the words §1.4 refuses. */
67
+ export function isGenericKeyword(value: string): boolean {
68
+ return GENERIC_KEYWORD_SET.has(normalizeKeyword(value));
69
+ }
package/src/index.ts CHANGED
@@ -6,6 +6,7 @@ export * from "./capabilities.js";
6
6
  export * from "./types.js";
7
7
  export * from "./define-extension.js";
8
8
  export * from "./manifest.js";
9
+ export * from "./generic-keywords.js";
9
10
  export * from "./permission-units.js";
10
11
  export * from "./view.js";
11
12
  export * from "./reactive.js";
package/src/manifest.ts CHANGED
@@ -13,6 +13,7 @@ import {
13
13
  CONTRIBUTION_MATCH_FIELDS,
14
14
  isContributableScopeType,
15
15
  } from "./types.js";
16
+ import { isGenericKeyword } from "./generic-keywords.js";
16
17
  import type { ContributableScopeType } from "./types.js";
17
18
  import {
18
19
  BOOLEAN_PERMISSION_GROUPS,
@@ -77,7 +78,21 @@ export interface ExtensionManifest {
77
78
  * Absent or empty for almost every extension. The forms above simply become undeclarable.
78
79
  */
79
80
  integratesWith?: string[];
80
- commands: ManifestCommand[];
81
+ /**
82
+ * Required for every package type but `skill`, which has none: a skill is markdown, and its
83
+ * surface is `documents` (spec 028 §1.3).
84
+ */
85
+ commands?: ManifestCommand[];
86
+ /**
87
+ * **The markdown a `skill` package carries** (spec 028 §1.3) — and the only thing it carries.
88
+ *
89
+ * A skill has no commands and no code. What it offers is documents that get handed to an
90
+ * assistant surface as context, so the manifest names them the way it names commands: declared,
91
+ * readable without unpacking anything, and localizable. The *bodies* stay in the package —
92
+ * the catalog index publishes only these titles, because a title is what a row shows and a body
93
+ * is something you get after you install it.
94
+ */
95
+ documents?: ManifestDocument[];
81
96
  /**
82
97
  * **Rows this extension adds to scopes it does not own** (architecture §11.3) — the third
83
98
  * right over a scope, next to owning one and producing an item that enters one.
@@ -142,6 +157,18 @@ export interface ExtensionManifest {
142
157
  optionalPermissions?: ManifestPermissions;
143
158
  }
144
159
 
160
+ /** One markdown document inside a `skill` package. */
161
+ export interface ManifestDocument {
162
+ /**
163
+ * Package-relative path to a `.md` file that is actually in the archive. Relative, no traversal,
164
+ * and `.md` — the same three rules an `asset:` reference lives by, for the same reason: this
165
+ * string is resolved against a directory on the user's disk.
166
+ */
167
+ path: string;
168
+ /** What the document is called. Localizable (`@key`), and the only part the index publishes. */
169
+ title: string;
170
+ }
171
+
145
172
  /**
146
173
  * One contribution: a native scope, which of its subjects, and the rows to add there.
147
174
  *
@@ -565,6 +592,16 @@ export interface ManifestPermissions {
565
592
  export interface ManifestProblem {
566
593
  path: string;
567
594
  message: string;
595
+ /**
596
+ * A stable rule id, for the problems the Swift mirror is measured against by name rather than
597
+ * by message (`@tapcue/extension-cli`'s `Problem.rule`, spec 028 §1.4).
598
+ *
599
+ * Absent on most problems, and that is not an oversight: the CLI reports an unnamed manifest
600
+ * problem as the generic `manifest.field`, which is all a corpus needs for a rule whose fixture
601
+ * nobody writes. A rule gets a name here when a fixture in `fixtures/malicious.ts` names it,
602
+ * because that name is the whole contract between the two implementations.
603
+ */
604
+ rule?: string;
568
605
  }
569
606
 
570
607
  /** Same grammar the shell resolves at runtime; see `scene.ts`. */
@@ -623,6 +660,9 @@ const COMMAND_ID_PATTERN = /^[a-z0-9]+(-[a-z0-9]+)*$/;
623
660
  const SEMVER_PATTERN = /^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$/;
624
661
  const HOST_PATTERN = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/;
625
662
 
663
+ /** How every check below says no. `rule` is set only where a fixture names one; see `ManifestProblem`. */
664
+ type Fail = (path: string, message: string, rule?: string) => void;
665
+
626
666
  /**
627
667
  * Validates a parsed manifest without executing any package code. Returns every
628
668
  * problem it finds rather than throwing on the first, so an author sees the full
@@ -630,7 +670,8 @@ const HOST_PATTERN = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-
630
670
  */
631
671
  export function validateManifest(value: unknown): ManifestProblem[] {
632
672
  const problems: ManifestProblem[] = [];
633
- const fail = (path: string, message: string) => problems.push({ path, message });
673
+ const fail: Fail = (path, message, rule) =>
674
+ problems.push(rule === undefined ? { path, message } : { path, message, rule });
634
675
 
635
676
  if (typeof value !== "object" || value === null || Array.isArray(value)) {
636
677
  return [{ path: "", message: "manifest must be a JSON object" }];
@@ -676,9 +717,17 @@ export function validateManifest(value: unknown): ManifestProblem[] {
676
717
  }
677
718
 
678
719
  const commands = m.commands;
679
- if (!Array.isArray(commands) || commands.length === 0) {
720
+ // A skill is markdown: it has no commands, and §1.3 says so in as many words. Demanding one
721
+ // would make the whole package type undeclarable, so the requirement is per type — and
722
+ // `documents` takes its place, checked in `validateDocuments`.
723
+ if (m.type === "skill") {
724
+ if (commands !== undefined) {
725
+ fail("commands", "a skill package has no commands — its surface is documents");
726
+ }
727
+ } else if (!Array.isArray(commands) || commands.length === 0) {
680
728
  fail("commands", "must declare at least one command");
681
- } else {
729
+ }
730
+ if (Array.isArray(commands)) {
682
731
  const seen = new Set<string>();
683
732
  commands.forEach((raw, index) => {
684
733
  const path = `commands[${index}]`;
@@ -715,6 +764,7 @@ export function validateManifest(value: unknown): ManifestProblem[] {
715
764
  fail(`${path}.activation.runsAtRoot`, "only a remote command can run at root");
716
765
  }
717
766
  validateInput(c.input as CommandInput | undefined, `${path}.input`, fail);
767
+ validateKeywords(c.keywords, `${path}.keywords`, fail);
718
768
  const surface = c.surface as CommandSurface | undefined;
719
769
  if (surface !== undefined) {
720
770
  if (surface.layout !== undefined && surface.layout !== "list" && surface.layout !== "grid") {
@@ -759,6 +809,7 @@ export function validateManifest(value: unknown): ManifestProblem[] {
759
809
  });
760
810
  }
761
811
 
812
+ validateDocuments(m, fail);
762
813
  const integrates = validateIntegratesWith(m, fail);
763
814
  validateContributes(m, integrates, fail);
764
815
  validateProvides(m, fail);
@@ -901,9 +952,258 @@ export function effectiveGrants(
901
952
 
902
953
  type PermissionRecord = Record<string, any>;
903
954
 
955
+ /** At most this many `keywords` on one command — see `validateKeywords`. */
956
+ export const MAX_COMMAND_KEYWORDS = 10;
957
+ /** The longest one keyword may be. */
958
+ export const MAX_KEYWORD_LENGTH = 32;
959
+
960
+ /**
961
+ * `keywords` — how many, how long, and which words are not keywords at all (spec 028 §1.4).
962
+ *
963
+ * A keyword is a claim on the user's typing: it puts this command in the candidate pool for a
964
+ * query that never mentions it by name. Three limits keep that claim proportionate.
965
+ *
966
+ * **Count** and **length** are about the index, which now holds every published extension rather
967
+ * than the handful someone installed: a hundred keywords per command is a way to be in every pool,
968
+ * and it costs the author nothing to write.
969
+ *
970
+ * **The blacklist** is about the pool itself (`generic-keywords.ts`). `open` belongs to every
971
+ * command there has ever been, so a command that claims it is not saying what it does — it is
972
+ * saying "consider me always", and the only thing left deciding is a tie-break.
973
+ */
974
+ function validateKeywords(value: unknown, path: string, fail: Fail): void {
975
+ if (value === undefined) return;
976
+ if (!Array.isArray(value)) {
977
+ fail(path, "must be an array of keywords");
978
+ return;
979
+ }
980
+ if (value.length > MAX_COMMAND_KEYWORDS) {
981
+ fail(
982
+ path,
983
+ `at most ${MAX_COMMAND_KEYWORDS} keywords — past that a command is not describing itself, it is claiming the query field`,
984
+ "manifest.keywords-count",
985
+ );
986
+ }
987
+ value.forEach((keyword, index) => {
988
+ const where = `${path}[${index}]`;
989
+ if (typeof keyword !== "string" || keyword.trim() === "") {
990
+ fail(where, "must be a non-empty string");
991
+ return;
992
+ }
993
+ if (keyword.length > MAX_KEYWORD_LENGTH) {
994
+ fail(where, `at most ${MAX_KEYWORD_LENGTH} characters`, "manifest.keywords-length");
995
+ }
996
+ if (isGenericKeyword(keyword)) {
997
+ fail(
998
+ where,
999
+ `"${keyword}" is a word nearly every command shares — a keyword has to narrow the pool, not join it`,
1000
+ "manifest.keywords-generic",
1001
+ );
1002
+ }
1003
+ });
1004
+ }
1005
+
904
1006
  /** The longest `pattern` a command may declare — see `CommandInput.pattern`. */
905
1007
  const MAX_INPUT_PATTERN_LENGTH = 200;
906
1008
 
1009
+ /**
1010
+ * Constructs a `pattern` may not use, because it runs in the launcher's process (spec 028 §1.4).
1011
+ *
1012
+ * The three refused families are the ones that turn a regular expression into a computation:
1013
+ * **nested quantifiers** (`(a+)+`) are the classic exponential blow-up, **backreferences** make the
1014
+ * language non-regular and the engine a backtracker, and **lookaround** re-runs a subpattern at
1015
+ * every position. None of the three is needed to describe an issue key or a tracking number, which
1016
+ * is what this field is for.
1017
+ *
1018
+ * **This check is deliberately syntactic, and it is not a ReDoS oracle.** It reads the pattern as
1019
+ * text — tracking escapes, character classes, and group nesting — and refuses the shapes above. It
1020
+ * does *not* find every pattern that can backtrack badly: `(a|a)+` and `(a|ab)*` are quadratic or
1021
+ * worse and pass this, as does any blow-up that needs alternation to see. What closes that gap is
1022
+ * elsewhere and is not a parser: the host caps the pattern's length (200), caps the length of the
1023
+ * text it is run against, and runs it off the keystroke path. This function removes the shapes an
1024
+ * attacker would reach for first; the caps are what make the rest uninteresting.
1025
+ */
1026
+ export function unsafePatternProblem(pattern: string): string | undefined {
1027
+ /** Whether each still-open group has a quantifier anywhere inside it. */
1028
+ const open: { quantified: boolean }[] = [];
1029
+ const markQuantified = () => {
1030
+ for (const group of open) group.quantified = true;
1031
+ };
1032
+
1033
+ let inClass = false;
1034
+ for (let i = 0; i < pattern.length; i += 1) {
1035
+ const char = pattern[i]!;
1036
+ if (char === "\\") {
1037
+ const next = pattern[i + 1];
1038
+ if (!inClass && next !== undefined && /[1-9]/.test(next)) {
1039
+ return "must not use a backreference — it makes the match a backtracking search";
1040
+ }
1041
+ if (!inClass && next === "k" && pattern[i + 2] === "<") {
1042
+ return "must not use a backreference — it makes the match a backtracking search";
1043
+ }
1044
+ i += 1; // the escaped character is never punctuation
1045
+ continue;
1046
+ }
1047
+ if (inClass) {
1048
+ if (char === "]") inClass = false;
1049
+ continue;
1050
+ }
1051
+ if (char === "[") {
1052
+ inClass = true;
1053
+ continue;
1054
+ }
1055
+ if (char === "(") {
1056
+ if (pattern.startsWith("(?=", i) || pattern.startsWith("(?!", i)) {
1057
+ return "must not use lookahead — it re-runs a subpattern at every position";
1058
+ }
1059
+ if (pattern.startsWith("(?<=", i) || pattern.startsWith("(?<!", i)) {
1060
+ return "must not use lookbehind — it re-runs a subpattern at every position";
1061
+ }
1062
+ open.push({ quantified: false });
1063
+ continue;
1064
+ }
1065
+ if (char === ")") {
1066
+ const group = open.pop();
1067
+ const quantifier = quantifierAt(pattern, i + 1);
1068
+ if (quantifier === undefined) continue;
1069
+ if (group?.quantified && quantifier.repeats) {
1070
+ return "must not nest quantifiers, e.g. (a+)+ — that is the shape that backtracks exponentially";
1071
+ }
1072
+ markQuantified();
1073
+ i = quantifier.end - 1;
1074
+ continue;
1075
+ }
1076
+ const quantifier = quantifierAt(pattern, i);
1077
+ if (quantifier !== undefined) {
1078
+ markQuantified();
1079
+ i = quantifier.end - 1;
1080
+ }
1081
+ }
1082
+ return undefined;
1083
+ }
1084
+
1085
+ /**
1086
+ * The quantifier starting at `index`, if there is one.
1087
+ *
1088
+ * `repeats` is what separates the dangerous outer quantifier from the harmless one: `(a+)?` may
1089
+ * match its group at most once, so nothing inside it can be re-tried against a different split.
1090
+ * `*`, `+`, `{n,}` and `{n,m}` with m ≥ 2 all can.
1091
+ */
1092
+ function quantifierAt(
1093
+ pattern: string,
1094
+ index: number,
1095
+ ): { end: number; repeats: boolean } | undefined {
1096
+ const char = pattern[index];
1097
+ if (char === "*" || char === "+") return { end: index + 1, repeats: true };
1098
+ if (char === "?") return { end: index + 1, repeats: false };
1099
+ if (char !== "{") return undefined;
1100
+ const close = pattern.indexOf("}", index);
1101
+ if (close < 0) return undefined; // a literal brace, which JavaScript tolerates
1102
+ const body = pattern.slice(index + 1, close);
1103
+ const match = /^(\d+)(,(\d*))?$/.exec(body);
1104
+ if (!match) return undefined;
1105
+ const min = Number(match[1]);
1106
+ const max = match[2] === undefined ? min : match[3] === "" ? Infinity : Number(match[3]);
1107
+ return { end: close + 1, repeats: max >= 2 };
1108
+ }
1109
+
1110
+ /** The longest a `contributes[].when.host` value may be — see `hostPatternProblem`. */
1111
+ export const MAX_CONTRIBUTION_HOST_LENGTH = 64;
1112
+
1113
+ /**
1114
+ * A `when.host` value: a host name, lowercase, optionally prefixed `*.` for its subdomains
1115
+ * (spec 028 §1.4).
1116
+ *
1117
+ * Every rule here is about the same thing — a host predicate is a *disclosure*, and the install
1118
+ * sheet reads it out. `*.github.com` says "the rows appear on GitHub", which somebody can decide
1119
+ * about. `*.com` says nothing at all while matching most of the web, so a wildcard needs a label
1120
+ * of its own below the suffix. Uppercase is refused rather than folded so that the string in the
1121
+ * manifest and the string on the sheet are the same string.
1122
+ */
1123
+ export function hostPatternProblem(value: string): string | undefined {
1124
+ if (value.length > MAX_CONTRIBUTION_HOST_LENGTH) {
1125
+ return `at most ${MAX_CONTRIBUTION_HOST_LENGTH} characters`;
1126
+ }
1127
+ const wildcard = value.startsWith("*.");
1128
+ const host = wildcard ? value.slice(2) : value;
1129
+ if (!/^[a-z0-9.-]+$/.test(host)) {
1130
+ return 'must be a lowercase host name — letters, digits, dots and hyphens, with an optional leading "*."';
1131
+ }
1132
+ const labels = host.split(".");
1133
+ for (const label of labels) {
1134
+ if (label === "") return "has an empty label — no leading, trailing or doubled dots";
1135
+ if (label.startsWith("-") || label.endsWith("-")) return `label "${label}" may not start or end with a hyphen`;
1136
+ }
1137
+ if (labels.length < 2) {
1138
+ // The known limit: this refuses `*.com`, not `*.co.uk`. Telling those apart needs the public
1139
+ // suffix list, which is a megabyte that expires — and the second door here is a human reading
1140
+ // a pull request, who can see a wildcard on a registry suffix for what it is.
1141
+ return wildcard
1142
+ ? `"${value}" is every site under a top-level domain — name the site, e.g. *.github.com`
1143
+ : "must be a full host name, e.g. github.com";
1144
+ }
1145
+ return undefined;
1146
+ }
1147
+
1148
+ /**
1149
+ * Whether a `when.host` value admits a subject's host.
1150
+ *
1151
+ * Whole-string and case-insensitive, like every other match field, with one addition: a leading
1152
+ * `*.` matches any *subdomain* and not the domain itself. `*.github.com` matches
1153
+ * `gist.github.com` and not `github.com` — a contribution that wants both writes both, because
1154
+ * the two are different claims and guessing which one an author meant is how a predicate quietly
1155
+ * widens.
1156
+ */
1157
+ export function hostMatches(pattern: string, host: string): boolean {
1158
+ const left = pattern.toLowerCase();
1159
+ const right = host.toLowerCase();
1160
+ if (!left.startsWith("*.")) return left === right;
1161
+ const suffix = left.slice(1); // ".github.com"
1162
+ return right.length > suffix.length && right.endsWith(suffix);
1163
+ }
1164
+
1165
+ /**
1166
+ * `documents` — the markdown a `skill` package carries, and nothing else may (spec 028 §1.3).
1167
+ *
1168
+ * The path rules are `asset:`'s: relative, no traversal, and here also `.md`, because these paths
1169
+ * are joined onto a directory in the user's store and then read. The title is checked for being
1170
+ * present rather than for being resolvable — a `@key` is resolved against the package's catalogs,
1171
+ * which is a question only the archive can answer (`manifest-check.ts`).
1172
+ */
1173
+ function validateDocuments(m: Record<string, unknown>, fail: Fail): void {
1174
+ const documents = m.documents;
1175
+ if (m.type !== "skill") {
1176
+ if (documents !== undefined) {
1177
+ fail("documents", 'only a "skill" package carries documents');
1178
+ }
1179
+ return;
1180
+ }
1181
+ if (!Array.isArray(documents) || documents.length === 0) {
1182
+ fail("documents", "a skill package must declare at least one markdown document");
1183
+ return;
1184
+ }
1185
+ const seen = new Set<string>();
1186
+ documents.forEach((raw, index) => {
1187
+ const path = `documents[${index}]`;
1188
+ const document = raw as Record<string, unknown>;
1189
+ const file = document?.path;
1190
+ if (typeof file !== "string" || file.trim() === "") {
1191
+ fail(`${path}.path`, "must name a markdown file inside the package");
1192
+ } else if (file.startsWith("/") || file.includes("\\") || file.split("/").includes("..")) {
1193
+ fail(`${path}.path`, "must be package-relative and must not traverse");
1194
+ } else if (!file.toLowerCase().endsWith(".md")) {
1195
+ fail(`${path}.path`, "must be a .md file — a skill is markdown");
1196
+ } else if (seen.has(file)) {
1197
+ fail(`${path}.path`, `duplicate document "${file}"`);
1198
+ } else {
1199
+ seen.add(file);
1200
+ }
1201
+ if (typeof document?.title !== "string" || document.title.trim() === "") {
1202
+ fail(`${path}.title`, "must say what the document is, for the row and the review sheet");
1203
+ }
1204
+ });
1205
+ }
1206
+
907
1207
  /**
908
1208
  * Validates a command's `input` block at install time, where a bad one costs nothing.
909
1209
  *
@@ -916,7 +1216,7 @@ const MAX_INPUT_PATTERN_LENGTH = 200;
916
1216
  function validateInput(
917
1217
  input: CommandInput | undefined,
918
1218
  path: string,
919
- fail: (path: string, message: string) => void,
1219
+ fail: Fail,
920
1220
  ): void {
921
1221
  if (input === undefined) return;
922
1222
  if (typeof input !== "object" || input === null) {
@@ -941,6 +1241,8 @@ function validateInput(
941
1241
  } else {
942
1242
  try {
943
1243
  new RegExp(input.pattern);
1244
+ const unsafe = unsafePatternProblem(input.pattern);
1245
+ if (unsafe) fail(`${path}.pattern`, unsafe, "manifest.pattern-unsafe");
944
1246
  } catch {
945
1247
  fail(`${path}.pattern`, "is not a valid regular expression");
946
1248
  }
@@ -1083,7 +1385,10 @@ export function manifestCatalogKeys(manifest: ExtensionManifest): string[] {
1083
1385
  };
1084
1386
  take(manifest.name);
1085
1387
  take(manifest.description);
1086
- for (const command of manifest.commands) {
1388
+ for (const document of manifest.documents ?? []) {
1389
+ take(document.title);
1390
+ }
1391
+ for (const command of manifest.commands ?? []) {
1087
1392
  take(command.title);
1088
1393
  take(command.description);
1089
1394
  for (const argument of command.arguments ?? []) {
@@ -1175,7 +1480,7 @@ export function scopeTypePrefix(extensionId: string): string {
1175
1480
  */
1176
1481
  function validateProvides(
1177
1482
  m: Record<string, unknown>,
1178
- fail: (path: string, message: string) => void,
1483
+ fail: Fail,
1179
1484
  ): void {
1180
1485
  const declaresWorkspaceRoots = (block: unknown): boolean =>
1181
1486
  (block as ManifestPermissions | undefined)?.workspace?.roots === true;
@@ -1224,7 +1529,7 @@ function validateProvides(
1224
1529
  function validateContributes(
1225
1530
  m: Record<string, unknown>,
1226
1531
  integrates: string[],
1227
- fail: (path: string, message: string) => void,
1532
+ fail: Fail,
1228
1533
  ): void {
1229
1534
  const contributes = m.contributes;
1230
1535
  if (contributes === undefined) return;
@@ -1297,6 +1602,10 @@ function validateContributes(
1297
1602
  `"${value}" is not in integratesWith — declare the apps this extension works with there`,
1298
1603
  );
1299
1604
  }
1605
+ if (field === "host") {
1606
+ const problem = hostPatternProblem(value);
1607
+ if (problem) fail(`${fieldPath}[${valueIndex}]`, problem, "manifest.host-invalid");
1608
+ }
1300
1609
  });
1301
1610
  }
1302
1611
  });
@@ -1316,7 +1625,7 @@ const MAX_SUGGESTION_RULES_LENGTH = 8_000;
1316
1625
  */
1317
1626
  function validateSuggestionRules(
1318
1627
  value: unknown,
1319
- fail: (path: string, message: string) => void,
1628
+ fail: Fail,
1320
1629
  ): void {
1321
1630
  if (value === undefined) return;
1322
1631
  let text: string;
@@ -1342,7 +1651,7 @@ function validateSuggestionRules(
1342
1651
  /** `integratesWith` — bundle identifiers, unique, and the anchor every other reach resolves against. */
1343
1652
  function validateIntegratesWith(
1344
1653
  m: Record<string, unknown>,
1345
- fail: (path: string, message: string) => void,
1654
+ fail: Fail,
1346
1655
  ): string[] {
1347
1656
  const raw = m.integratesWith;
1348
1657
  if (raw === undefined) return [];
@@ -1382,7 +1691,7 @@ function validateRootPath(
1382
1691
  root: FileRoot,
1383
1692
  value: unknown,
1384
1693
  path: string,
1385
- fail: (path: string, message: string) => void,
1694
+ fail: Fail,
1386
1695
  ): void {
1387
1696
  if (ROOT_PATH_SHAPES[root] === "dot-segment") {
1388
1697
  if (typeof value !== "string" || value.trim() === "") {
@@ -1416,7 +1725,7 @@ function validateRootPath(
1416
1725
  function validateReadOnlySql(
1417
1726
  value: unknown,
1418
1727
  path: string,
1419
- fail: (path: string, message: string) => void,
1728
+ fail: Fail,
1420
1729
  ): void {
1421
1730
  if (typeof value !== "string" || value.trim() === "") {
1422
1731
  fail(path, "must be a SELECT statement");
@@ -1442,7 +1751,7 @@ function validateReach(
1442
1751
  permissions: ManifestPermissions | undefined,
1443
1752
  block: string,
1444
1753
  integrates: string[],
1445
- fail: (path: string, message: string) => void,
1754
+ fail: Fail,
1446
1755
  ): void {
1447
1756
  if (!permissions) return;
1448
1757
 
@@ -1559,7 +1868,7 @@ function validateReach(
1559
1868
  }
1560
1869
 
1561
1870
  /** A subpath inside a host-chosen root: no wildcards, no climbing, no absolute form. */
1562
- function validateSubpath(value: string, path: string, fail: (path: string, message: string) => void): void {
1871
+ function validateSubpath(value: string, path: string, fail: Fail): void {
1563
1872
  if (value.trim() === "") {
1564
1873
  fail(path, "must be a non-empty subpath");
1565
1874
  } else if (value.startsWith("/") || value.startsWith("~")) {
@@ -78,6 +78,7 @@ import {
78
78
  type ManifestPermissions,
79
79
  coerceSetting,
80
80
  effectiveGrants,
81
+ hostMatches,
81
82
  iconProblem,
82
83
  namespacedItemId,
83
84
  permissionUnits,
@@ -122,6 +123,7 @@ const NATIVE_SUBJECT_FIELDS: Readonly<Record<ContributableScopeType, readonly st
122
123
  application: ["bundleId", "name"],
123
124
  file: ["path", "name", "extension", "isDirectory"],
124
125
  "file.folder": ["path", "name", "extension", "isDirectory"],
126
+ url: ["url", "host"],
125
127
  });
126
128
 
127
129
  export interface HostLimits {
@@ -1368,7 +1370,7 @@ export function createTestHost(options: TestHostOptions): TestHost {
1368
1370
  }
1369
1371
 
1370
1372
  function commandOf(commandId: string): ManifestCommand {
1371
- const command = manifest.commands.find((c) => c.id === commandId);
1373
+ const command = (manifest.commands ?? []).find((c) => c.id === commandId);
1372
1374
  if (!command) throw new Error(`command "${commandId}" is not declared in the manifest`);
1373
1375
  return command;
1374
1376
  }
@@ -1701,6 +1703,12 @@ export function createTestHost(options: TestHostOptions): TestHost {
1701
1703
  if (!matchable.includes(field)) return false;
1702
1704
  const actual = subject[field];
1703
1705
  if (typeof actual !== "string") return false;
1706
+ // `host` is the one field with a wildcard form, and `hostMatches` is the only place it is
1707
+ // read — the shell and this fake must agree that `*.github.com` is not `github.com`.
1708
+ if (field === "host") {
1709
+ if (!values.some((value) => hostMatches(value, actual))) return false;
1710
+ continue;
1711
+ }
1704
1712
  const lowered = actual.toLowerCase();
1705
1713
  if (!values.some((value) => value.toLowerCase() === lowered)) return false;
1706
1714
  }
@@ -1913,7 +1921,7 @@ export function createTestHost(options: TestHostOptions): TestHost {
1913
1921
  // The command whose query produced this item — not `commands[0]`. An action
1914
1922
  // inside the scope is still routed to (extension, command, item, action), and
1915
1923
  // an extension with two commands would otherwise be told the wrong one.
1916
- const commandId = itemOrigin.get(item.id) ?? manifest.commands[0].id;
1924
+ const commandId = itemOrigin.get(item.id) ?? manifest.commands?.[0]?.id ?? "";
1917
1925
  // Entering a scope tears down whatever surface the previous one had up. The isolate does
1918
1926
  // not own the surface, the shell does, and leaving one scope ends it — so the bridge from
1919
1927
  // a stale surface is gone before the next scope's actions can reach for it.
package/src/types.ts CHANGED
@@ -59,6 +59,7 @@ export interface NativeSubjects {
59
59
  application: ApplicationSubject;
60
60
  file: FileSubject;
61
61
  "file.folder": FileSubject;
62
+ url: UrlSubject;
62
63
  }
63
64
 
64
65
  /** An installed application, as the `application` scope names it. */
@@ -79,6 +80,23 @@ export interface FileSubject {
79
80
  isDirectory: boolean;
80
81
  }
81
82
 
83
+ /**
84
+ * A web address, as the `url` scope names it — a link the user is standing on, or the page the
85
+ * frontmost browser is showing.
86
+ *
87
+ * Two fields and no page content. `host` is separate from `url` because it is the only thing a
88
+ * contribution may *match* on (`CONTRIBUTION_MATCH_FIELDS`), and deriving it from the address on
89
+ * both sides of the boundary would be two parsers disagreeing about punycode and ports. The title,
90
+ * the description and the rest of the page are not here: reading them is a fetch, and a subject is
91
+ * something the host already knows.
92
+ */
93
+ export interface UrlSubject {
94
+ /** The full address, normalized: scheme and host lowercased, no fragment. */
95
+ url: string;
96
+ /** The lowercased host, no port and no userinfo. `""` for an address that has none. */
97
+ host: string;
98
+ }
99
+
82
100
  /**
83
101
  * **The native scopes an extension may contribute rows *into*** (architecture §11.3).
84
102
  *
@@ -93,7 +111,7 @@ export interface FileSubject {
93
111
  * **list of rows** there is something to add to. `color` has a subject and no list — it is a
94
112
  * value Tapcue formats, not a place — so it is not here.
95
113
  */
96
- export const CONTRIBUTABLE_SCOPE_TYPES = ["application", "file", "file.folder"] as const;
114
+ export const CONTRIBUTABLE_SCOPE_TYPES = ["application", "file", "file.folder", "url"] as const;
97
115
  export type ContributableScopeType = (typeof CONTRIBUTABLE_SCOPE_TYPES)[number];
98
116
 
99
117
  /**
@@ -103,6 +121,11 @@ export type ContributableScopeType = (typeof CONTRIBUTABLE_SCOPE_TYPES)[number];
103
121
  * is a glob problem, and a predicate that could say "anything under /Users" would hand an
104
122
  * extension every file you ever open a scope on. What a contribution may name is a *kind* of
105
123
  * subject (this app, this file extension), never a location.
124
+ *
125
+ * `url` names `host` for the same reason and nothing else: the address itself is a location, and a
126
+ * predicate over it would be the glob problem again. The one concession is the `*.` prefix
127
+ * (`hostMatches`), because "this site" and "this site's subdomains" are genuinely different asks
128
+ * and neither is expressible as the other.
106
129
  */
107
130
  export const CONTRIBUTION_MATCH_FIELDS: Readonly<
108
131
  Record<ContributableScopeType, readonly string[]>
@@ -110,6 +133,7 @@ export const CONTRIBUTION_MATCH_FIELDS: Readonly<
110
133
  application: ["bundleId", "name"],
111
134
  file: ["extension", "name"],
112
135
  "file.folder": ["name"],
136
+ url: ["host"],
113
137
  });
114
138
 
115
139
  export function isContributableScopeType(type: string): type is ContributableScopeType {