@savvy-web/silk-effects 7.0.1 → 7.1.1

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.
Files changed (33) hide show
  1. package/README.md +4 -1
  2. package/changesets/api/linter.js +1 -1
  3. package/changesets/api/transformer.js +10 -7
  4. package/changesets/changelog/getReleaseLine.js +114 -20
  5. package/changesets/changelog/vanilla.js +47 -0
  6. package/changesets/constants.js +7 -2
  7. package/changesets/index.js +7 -4
  8. package/changesets/markdownlint/rules/content-structure.js +2 -2
  9. package/changesets/markdownlint/rules/dependency-table-format.js +12 -11
  10. package/changesets/markdownlint/rules/heading-hierarchy.js +2 -2
  11. package/changesets/markdownlint/rules/required-sections.js +2 -2
  12. package/changesets/markdownlint/rules/uncategorized-content.js +3 -3
  13. package/changesets/markdownlint/rules/utils.js +17 -6
  14. package/changesets/remark/plugins/aggregate-dependency-tables.js +53 -1
  15. package/changesets/remark/plugins/contributor-footnotes.js +276 -64
  16. package/changesets/remark/plugins/reorder-sections.js +18 -3
  17. package/changesets/remark/presets.js +1 -1
  18. package/changesets/remark/rules/dependency-table-format.js +7 -9
  19. package/changesets/schemas/dependency-table.js +11 -3
  20. package/changesets/schemas/options.js +8 -0
  21. package/changesets/services/config-inspector.js +51 -4
  22. package/changesets/services/deps-regen.js +106 -10
  23. package/changesets/services/release-planner.js +29 -8
  24. package/changesets/utils/dep-diff.js +34 -5
  25. package/changesets/utils/dependency-section.js +34 -0
  26. package/changesets/utils/dependency-table.js +14 -9
  27. package/changesets/utils/markdown-emit.js +86 -0
  28. package/changesets/utils/remark-pipeline.js +14 -85
  29. package/changesets/utils/section-parser.js +4 -2
  30. package/index.d.ts +158 -24
  31. package/index.js +1 -1
  32. package/lint/index.js +1 -1
  33. package/package.json +6 -4
@@ -9,6 +9,12 @@ import { visit } from "unist-util-visit";
9
9
  */
10
10
  const ATTRIBUTION_PLAIN_RE = /\s*Thanks @(\w[\w-]*)!$/;
11
11
  /**
12
+ * Pattern matching `@user` mentions inside an existing Thanks section.
13
+ *
14
+ * @internal
15
+ */
16
+ const MENTION_RE = /@(\w[\w-]*)/g;
17
+ /**
12
18
  * Try to extract a linked attribution from the end of a paragraph's children.
13
19
  * Pattern: text "...Thanks " + link "\@user" + text "!"
14
20
  *
@@ -40,81 +46,287 @@ function extractLinkedAttribution(children) {
40
46
  removeFrom: children.length - 3
41
47
  };
42
48
  }
43
- const ContributorFootnotesPlugin = () => {
49
+ /**
50
+ * Strip a trailing attribution (linked or plain) from a paragraph, adding
51
+ * the contributor to the map.
52
+ *
53
+ * @param para - The paragraph to inspect and mutate
54
+ * @param contributors - The per-block contributor accumulator
55
+ * @returns `true` when an attribution was found and removed
56
+ *
57
+ * @internal
58
+ */
59
+ function stripAttribution(para, contributors) {
60
+ const linked = extractLinkedAttribution(para.children);
61
+ if (linked) {
62
+ const key = linked.contributor.username.toLowerCase();
63
+ if (!contributors.has(key)) contributors.set(key, linked.contributor);
64
+ const textNode = para.children[linked.removeFrom];
65
+ textNode.value = textNode.value.replace(/\s*Thanks $/, "");
66
+ para.children.splice(linked.removeFrom + 1, 2);
67
+ if (textNode.value === "") para.children.splice(linked.removeFrom, 1);
68
+ return true;
69
+ }
70
+ const last = para.children[para.children.length - 1];
71
+ if (last?.type === "text") {
72
+ const textNode = last;
73
+ const match = textNode.value.match(ATTRIBUTION_PLAIN_RE);
74
+ if (match) {
75
+ const username = match[1];
76
+ const key = username.toLowerCase();
77
+ if (!contributors.has(key)) contributors.set(key, {
78
+ username,
79
+ url: void 0
80
+ });
81
+ textNode.value = textNode.value.replace(ATTRIBUTION_PLAIN_RE, "");
82
+ if (textNode.value === "") para.children.pop();
83
+ return true;
84
+ }
85
+ }
86
+ return false;
87
+ }
88
+ /**
89
+ * Harvest every `@user` mention (linked or plain) from a node subtree.
90
+ * Used to absorb an existing `### Thanks` section so re-running the plugin
91
+ * is idempotent.
92
+ *
93
+ * @internal
94
+ */
95
+ function harvestMentions(node, contributors) {
96
+ visit(node, (child) => {
97
+ if (child.type === "link") {
98
+ const link = child;
99
+ const only = link.children.length === 1 ? link.children[0] : void 0;
100
+ if (only?.type === "text" && only.value.startsWith("@")) {
101
+ const username = only.value.slice(1);
102
+ const key = username.toLowerCase();
103
+ if (!contributors.has(key)) contributors.set(key, {
104
+ username,
105
+ url: link.url
106
+ });
107
+ return "skip";
108
+ }
109
+ } else if (child.type === "text") for (const match of child.value.matchAll(MENTION_RE)) {
110
+ const key = match[1].toLowerCase();
111
+ if (!contributors.has(key)) contributors.set(key, {
112
+ username: match[1],
113
+ url: void 0
114
+ });
115
+ }
116
+ });
117
+ }
118
+ /**
119
+ * Build the `Thanks to ... for their contributions!` summary paragraph.
120
+ *
121
+ * @internal
122
+ */
123
+ function buildSummaryParagraph(contributors) {
124
+ const sorted = [...contributors.values()].sort((a, b) => a.username.toLowerCase().localeCompare(b.username.toLowerCase()));
125
+ const phrasingChildren = [];
126
+ phrasingChildren.push({
127
+ type: "text",
128
+ value: "Thanks to "
129
+ });
130
+ for (let i = 0; i < sorted.length; i++) {
131
+ const contrib = sorted[i];
132
+ if (i > 0 && sorted.length > 2) phrasingChildren.push({
133
+ type: "text",
134
+ value: ", "
135
+ });
136
+ if (i > 0 && i === sorted.length - 1) phrasingChildren.push({
137
+ type: "text",
138
+ value: sorted.length === 2 ? " and " : "and "
139
+ });
140
+ if (contrib.url) phrasingChildren.push({
141
+ type: "link",
142
+ url: contrib.url,
143
+ children: [{
144
+ type: "text",
145
+ value: `@${contrib.username}`
146
+ }]
147
+ });
148
+ else phrasingChildren.push({
149
+ type: "text",
150
+ value: `@${contrib.username}`
151
+ });
152
+ }
153
+ phrasingChildren.push({
154
+ type: "text",
155
+ value: " for their contributions!"
156
+ });
157
+ return {
158
+ type: "paragraph",
159
+ children: phrasingChildren
160
+ };
161
+ }
162
+ /**
163
+ * Remove list items (recursively) whose content was emptied by attribution
164
+ * stripping — a bullet that held ONLY `Thanks @user!` must not survive as a
165
+ * bare `- ` husk. Runs in both the `thanks: true` and `thanks: false` paths,
166
+ * since stripping is unconditional.
167
+ *
168
+ * @internal
169
+ */
170
+ function pruneEmptiedListItems(list) {
171
+ list.children = list.children.filter((item) => {
172
+ item.children = item.children.filter((child) => {
173
+ if (child.type === "paragraph") return child.children.length > 0;
174
+ if (child.type === "list") {
175
+ const nested = child;
176
+ pruneEmptiedListItems(nested);
177
+ return nested.children.length > 0;
178
+ }
179
+ return true;
180
+ });
181
+ return item.children.length > 0;
182
+ });
183
+ }
184
+ /**
185
+ * Text form of a paragraph: concatenated text values, with single-text link
186
+ * children flattened to their label. Returns `undefined` when the paragraph
187
+ * holds anything other than text and simple text-labelled links.
188
+ *
189
+ * @internal
190
+ */
191
+ function flattenToText(para) {
192
+ let out = "";
193
+ for (const child of para.children) {
194
+ if (child.type === "text") {
195
+ out += child.value;
196
+ continue;
197
+ }
198
+ if (child.type === "link") {
199
+ const link = child;
200
+ const only = link.children.length === 1 ? link.children[0] : void 0;
201
+ if (only?.type === "text") {
202
+ out += only.value;
203
+ continue;
204
+ }
205
+ }
206
+ return;
207
+ }
208
+ return out;
209
+ }
210
+ /**
211
+ * Pattern matching the plugin's own merged summary paragraph, e.g.
212
+ * `Thanks to @alice, @bob, and @carol for their contributions!`.
213
+ *
214
+ * @internal
215
+ */
216
+ const MERGED_SUMMARY_RE = /^Thanks to @\w[\w-]*(?:(?:, | and |, and |,and )@\w[\w-]*)* for their contributions!$/;
217
+ /**
218
+ * Whether a paragraph consists ENTIRELY of attribution content the plugin
219
+ * owns: either its own merged `Thanks to ... for their contributions!`
220
+ * summary, or one or more trailing `Thanks @user!` / `Thanks [@user](url)!`
221
+ * attributions with nothing else. Checked against a clone so the real node
222
+ * is never mutated — a paragraph carrying anything beyond pure attribution
223
+ * is not the plugin's to touch.
224
+ *
225
+ * @internal
226
+ */
227
+ function isPureAttributionParagraph(para) {
228
+ const text = flattenToText(para);
229
+ if (text !== void 0 && MERGED_SUMMARY_RE.test(text)) return true;
230
+ const clone = structuredClone(para);
231
+ const scratch = /* @__PURE__ */ new Map();
232
+ while (clone.children.length > 0 && stripAttribution(clone, scratch));
233
+ return clone.children.length === 0;
234
+ }
235
+ /**
236
+ * Whether a Thanks-section body node is a pure attribution shape the plugin
237
+ * may harvest and remove: an attribution-only paragraph, or a list whose
238
+ * every item holds only attribution-only paragraphs. Anything else
239
+ * (mixed prose+mention, plain-name lists, code blocks, arbitrary prose) is
240
+ * preserved untouched and never mined for mentions.
241
+ *
242
+ * @internal
243
+ */
244
+ function isPureAttributionNode(node) {
245
+ if (node.type === "paragraph") return isPureAttributionParagraph(node);
246
+ if (node.type === "list") {
247
+ const list = node;
248
+ return list.children.length > 0 && list.children.every((item) => item.children.length > 0 && item.children.every((child) => child.type === "paragraph" && isPureAttributionParagraph(child)));
249
+ }
250
+ return false;
251
+ }
252
+ /**
253
+ * Whether a node is a depth-3 heading whose text is `Thanks`.
254
+ *
255
+ * @internal
256
+ */
257
+ function isThanksHeading(node) {
258
+ if (node.type !== "heading" || node.depth !== 3) return false;
259
+ const heading = node;
260
+ const only = heading.children.length === 1 ? heading.children[0] : void 0;
261
+ return only?.type === "text" && only.value.trim().toLowerCase() === "thanks";
262
+ }
263
+ const ContributorFootnotesPlugin = (options) => {
264
+ const emitThanks = options?.thanks !== false;
44
265
  return (tree) => {
45
266
  const blocks = getVersionBlocks(tree);
46
267
  for (let b = blocks.length - 1; b >= 0; b--) {
47
268
  const block = blocks[b];
48
269
  const contributors = /* @__PURE__ */ new Map();
270
+ const indicesToRemove = [];
271
+ let preservedThanksAnchor;
49
272
  for (let i = block.startIndex; i < block.endIndex; i++) {
50
273
  const node = tree.children[i];
51
- if (node.type !== "list") continue;
52
- visit(node, "paragraph", (para) => {
53
- const linked = extractLinkedAttribution(para.children);
54
- if (linked) {
55
- const key = linked.contributor.username.toLowerCase();
56
- if (!contributors.has(key)) contributors.set(key, linked.contributor);
57
- const textNode = para.children[linked.removeFrom];
58
- textNode.value = textNode.value.replace(/\s*Thanks $/, "");
59
- para.children.splice(linked.removeFrom + 1, 2);
60
- if (textNode.value === "") para.children.splice(linked.removeFrom, 1);
61
- return;
274
+ if (node.type === "list") {
275
+ const listNode = node;
276
+ visit(listNode, "paragraph", (para) => {
277
+ stripAttribution(para, contributors);
278
+ });
279
+ pruneEmptiedListItems(listNode);
280
+ if (listNode.children.length === 0) indicesToRemove.push(i);
281
+ continue;
282
+ }
283
+ if (node.type === "paragraph") {
284
+ const para = node;
285
+ if (stripAttribution(para, contributors) && para.children.length === 0) indicesToRemove.push(i);
286
+ continue;
287
+ }
288
+ if (isThanksHeading(node)) {
289
+ const headingIndex = i;
290
+ const harvested = [];
291
+ let lastPreserved;
292
+ let j = i + 1;
293
+ for (; j < block.endIndex; j++) {
294
+ const contentNode = tree.children[j];
295
+ if (contentNode.type === "heading" && [2, 3].includes(contentNode.depth)) break;
296
+ if (contentNode.type === "definition") continue;
297
+ if (isPureAttributionNode(contentNode)) {
298
+ harvestMentions(contentNode, contributors);
299
+ harvested.push(j);
300
+ } else lastPreserved = contentNode;
62
301
  }
63
- const last = para.children[para.children.length - 1];
64
- if (last?.type === "text") {
65
- const textNode = last;
66
- const match = textNode.value.match(ATTRIBUTION_PLAIN_RE);
67
- if (match) {
68
- const username = match[1];
69
- const key = username.toLowerCase();
70
- if (!contributors.has(key)) contributors.set(key, {
71
- username,
72
- url: void 0
73
- });
74
- textNode.value = textNode.value.replace(ATTRIBUTION_PLAIN_RE, "");
75
- }
302
+ if (lastPreserved === void 0) indicesToRemove.push(headingIndex, ...harvested);
303
+ else {
304
+ indicesToRemove.push(...harvested);
305
+ preservedThanksAnchor ??= lastPreserved;
76
306
  }
77
- });
307
+ i = j - 1;
308
+ }
78
309
  }
79
- if (contributors.size === 0) continue;
80
- const sorted = [...contributors.values()].sort((a, b) => a.username.toLowerCase().localeCompare(b.username.toLowerCase()));
81
- const phrasingChildren = [];
82
- phrasingChildren.push({
83
- type: "text",
84
- value: "Thanks to "
85
- });
86
- for (let i = 0; i < sorted.length; i++) {
87
- const contrib = sorted[i];
88
- if (i > 0 && sorted.length > 2) phrasingChildren.push({
89
- type: "text",
90
- value: ", "
91
- });
92
- if (i > 0 && i === sorted.length - 1) phrasingChildren.push({
93
- type: "text",
94
- value: sorted.length === 2 ? " and " : "and "
95
- });
96
- if (contrib.url) phrasingChildren.push({
97
- type: "link",
98
- url: contrib.url,
99
- children: [{
100
- type: "text",
101
- value: `@${contrib.username}`
102
- }]
103
- });
104
- else phrasingChildren.push({
105
- type: "text",
106
- value: `@${contrib.username}`
107
- });
310
+ const uniqueIndices = [...new Set(indicesToRemove)].sort((a, b) => b - a);
311
+ for (const idx of uniqueIndices) tree.children.splice(idx, 1);
312
+ if (!emitThanks || contributors.size === 0) continue;
313
+ if (preservedThanksAnchor !== void 0) {
314
+ const anchorIndex = tree.children.indexOf(preservedThanksAnchor);
315
+ if (anchorIndex !== -1) {
316
+ tree.children.splice(anchorIndex + 1, 0, buildSummaryParagraph(contributors));
317
+ continue;
318
+ }
108
319
  }
109
- phrasingChildren.push({
110
- type: "text",
111
- value: " for their contributions!"
112
- });
113
- const paragraph = {
114
- type: "paragraph",
115
- children: phrasingChildren
116
- };
117
- tree.children.splice(block.endIndex, 0, paragraph);
320
+ let insertAt = block.endIndex - uniqueIndices.length;
321
+ while (insertAt > block.startIndex && tree.children[insertAt - 1]?.type === "definition") insertAt--;
322
+ tree.children.splice(insertAt, 0, {
323
+ type: "heading",
324
+ depth: 3,
325
+ children: [{
326
+ type: "text",
327
+ value: "Thanks"
328
+ }]
329
+ }, buildSummaryParagraph(contributors));
118
330
  }
119
331
  };
120
332
  };
@@ -8,6 +8,23 @@ import { getBlockSections, getHeadingText, getVersionBlocks } from "../../utils/
8
8
  * @internal
9
9
  */
10
10
  const UNKNOWN_PRIORITY = 999;
11
+ /**
12
+ * Priority for the `Thanks` section emitted by {@link ContributorFootnotesPlugin}.
13
+ * Sorts after everything — including unknown headings — so the contributor
14
+ * credits always close the version block.
15
+ *
16
+ * @internal
17
+ */
18
+ const THANKS_PRIORITY = 1e3;
19
+ /**
20
+ * Resolve the sort priority for a section heading.
21
+ *
22
+ * @internal
23
+ */
24
+ function headingPriority(text) {
25
+ if (text.trim().toLowerCase() === "thanks") return THANKS_PRIORITY;
26
+ return fromHeading(text)?.priority ?? UNKNOWN_PRIORITY;
27
+ }
11
28
  const ReorderSectionsPlugin = () => {
12
29
  return (tree) => {
13
30
  const blocks = getVersionBlocks(tree);
@@ -18,9 +35,7 @@ const ReorderSectionsPlugin = () => {
18
35
  const preamble = [];
19
36
  for (let i = block.startIndex; i < block.endIndex; i++) if (i < sections[0].headingIndex) preamble.push(tree.children[i]);
20
37
  else break;
21
- const sorted = [...sections].sort((a, b) => {
22
- return (fromHeading(getHeadingText(a.heading))?.priority ?? UNKNOWN_PRIORITY) - (fromHeading(getHeadingText(b.heading))?.priority ?? UNKNOWN_PRIORITY);
23
- });
38
+ const sorted = [...sections].sort((a, b) => headingPriority(getHeadingText(a.heading)) - headingPriority(getHeadingText(b.heading)));
24
39
  if (sorted.every((s, i) => s.headingIndex === sections[i].headingIndex)) continue;
25
40
  const newChildren = [...preamble];
26
41
  for (const section of sorted) newChildren.push(section.heading, ...section.contentNodes);
@@ -3,8 +3,8 @@ import { DependencyTableFormatRule } from "./rules/dependency-table-format.js";
3
3
  import { HeadingHierarchyRule } from "./rules/heading-hierarchy.js";
4
4
  import { RequiredSectionsRule } from "./rules/required-sections.js";
5
5
  import { UncategorizedContentRule } from "./rules/uncategorized-content.js";
6
- import { AggregateDependencyTablesPlugin } from "./plugins/aggregate-dependency-tables.js";
7
6
  import { ContributorFootnotesPlugin } from "./plugins/contributor-footnotes.js";
7
+ import { AggregateDependencyTablesPlugin } from "./plugins/aggregate-dependency-tables.js";
8
8
  import { DeduplicateItemsPlugin } from "./plugins/deduplicate-items.js";
9
9
  import { IssueLinkRefsPlugin } from "./plugins/issue-link-refs.js";
10
10
  import { MergeSectionsPlugin } from "./plugins/merge-sections.js";
@@ -1,5 +1,6 @@
1
1
  import { parseDependencyTable } from "../../utils/dependency-table.js";
2
2
  import { RULE_DOCS } from "../../constants.js";
3
+ import { scanDependencySection } from "../../utils/dependency-section.js";
3
4
  import { toString } from "mdast-util-to-string";
4
5
  import { lintRule } from "unified-lint-rule";
5
6
  import { visit } from "unist-util-visit";
@@ -12,18 +13,15 @@ const DependencyTableFormatRule = lintRule("remark-lint:changeset-dependency-tab
12
13
  if (node.depth !== 2) return;
13
14
  if (toString(node).toLowerCase() !== "dependencies") return;
14
15
  if (index === void 0) return;
15
- const content = [];
16
- for (let i = index + 1; i < tree.children.length; i++) {
17
- const child = tree.children[i];
18
- if (child.type === "heading") break;
19
- content.push(child);
20
- }
21
- const tables = content.filter((n) => n.type === "table");
22
- if (tables.length === 0) {
16
+ const scan = scanDependencySection(tree.children, index + 1, {
17
+ isHeading: (n) => n.type === "heading",
18
+ isTable: (n) => n.type === "table"
19
+ });
20
+ if (scan.table === void 0) {
23
21
  file.message(`Dependencies section must contain a table, not a list or paragraph. See: ${RULE_DOCS.CSH005}`, node);
24
22
  return;
25
23
  }
26
- const table = tables[0];
24
+ const table = scan.table;
27
25
  try {
28
26
  const rows = parseDependencyTable(table);
29
27
  for (const row of rows) {
@@ -52,8 +52,14 @@ const DependencyActionSchema = Schema.Literals([
52
52
  * @remarks
53
53
  * Unlike {@link DependencyTypeSchema} (which uses plural npm field names like
54
54
  * `"dependencies"`), this schema uses singular forms (`"dependency"`) and adds
55
- * two additional types: `"workspace"` for monorepo workspace references and
56
- * `"config"` for configuration toolchain updates (e.g., ESLint, TypeScript).
55
+ * four additional types with no `package.json` dependency-field counterpart:
56
+ * `"workspace"` for monorepo workspace references, `"config"` for
57
+ * configuration toolchain updates (e.g., ESLint, TypeScript), `"runtime"` for
58
+ * language-runtime upgrades (e.g., the Node.js engine itself), and
59
+ * `"packageManager"` for the package manager's own self-upgrade (pnpm, bun,
60
+ * npm). `"runtime"` and `"packageManager"` are release-neutral — like
61
+ * `devDependency` rows they document toolchain movement without implying a
62
+ * consumer-facing version bump.
57
63
  *
58
64
  * @example
59
65
  * ```typescript
@@ -76,7 +82,9 @@ const DependencyTableTypeSchema = Schema.Literals([
76
82
  "peerDependency",
77
83
  "optionalDependency",
78
84
  "workspace",
79
- "config"
85
+ "config",
86
+ "runtime",
87
+ "packageManager"
80
88
  ]);
81
89
  /**
82
90
  * The canonical accepted-value pattern for a dependency-table From/To cell:
@@ -106,6 +106,14 @@ const ChangesetOptionsSchema = Schema.Struct({
106
106
  /** Custom issue reference prefixes (e.g., `["#", "GH-"]`). */
107
107
  issuePrefixes: Schema.optional(Schema.Array(Schema.String)),
108
108
  /**
109
+ * Whether to credit contributors with `Thanks \@user!` attributions
110
+ * (aggregated into a `### Thanks` section by the transform pipeline).
111
+ * Defaults to `true`; set `false` to strip attribution entirely —
112
+ * no inline thanks and no Thanks section. PR references are kept
113
+ * either way.
114
+ */
115
+ thanks: Schema.optional(Schema.Boolean),
116
+ /**
109
117
  * Per-package release surfaces. Each entry declares `additionalScopes`
110
118
  * (globs outside the workspace dir that belong to the package) and
111
119
  * `versionFiles` (files bumped in lockstep with the package's version).
@@ -1,9 +1,10 @@
1
1
  import { ConfigurationError } from "../errors.js";
2
2
  import { ChangesetOptionsSchema } from "../schemas/options.js";
3
+ import { MARKDOWNLINT_CONFIG_PATH } from "../../lint/cli/sections.js";
3
4
  import { ChangesetConfigReader } from "../../services/ChangesetConfigReader.js";
4
5
  import { SilkPublishability, readTargetsBinding } from "../../services/SilkPublishability.js";
5
6
  import { Context, Effect, FileSystem, Layer, Path, Result, Schema } from "effect";
6
- import { isAbsolute, join, relative, resolve } from "node:path";
7
+ import { isAbsolute, join, relative, resolve, sep } from "node:path";
7
8
  import { GlobPattern, GlobPatternOptions } from "@effected/glob";
8
9
  import { compileAndExpand } from "@effected/walker";
9
10
  import { WorkspaceDiscovery } from "@effected/workspaces";
@@ -78,6 +79,10 @@ const ClassificationReasonSchema = Schema.Union([
78
79
  kind: Schema.Literal("versionFile"),
79
80
  glob: Schema.String
80
81
  }),
82
+ Schema.Struct({
83
+ kind: Schema.Literal("unmappedHint"),
84
+ hint: Schema.String.annotate({ description: "Why this UNMAPPED path is probably not an unattributed change: it matches a config glob whose file no longer materializes (deleted versionFiles / additionalScopes target) or mirrors a known package template. The package stays null — a hint is context, not attribution." })
85
+ }),
81
86
  Schema.Null
82
87
  ]).annotate({ identifier: "ClassificationReason" });
83
88
  /** The result of classifying a single path against a resolved config. @public */
@@ -398,7 +403,7 @@ function makeShape(reader, discovery, fs) {
398
403
  throw e;
399
404
  }
400
405
  const decodedOptions = yield* Schema.decodeUnknownEffect(ChangesetOptionsSchema)(normalized).pipe(Effect.mapError((parseError) => configErrorFromParseError(parseError, configPath)));
401
- const workspaces = (yield* discovery.listPackages().pipe(Effect.mapError((err) => new ConfigurationError({
406
+ const workspaces = (yield* discovery.listPackagesIn(projectDir).pipe(Effect.mapError((err) => new ConfigurationError({
402
407
  field: "workspace",
403
408
  reason: `Workspace discovery failed for ${projectDir}: ${err.message}`
404
409
  })))).map((w) => ({
@@ -448,13 +453,43 @@ function makeShape(reader, discovery, fs) {
448
453
  cache.clear();
449
454
  yield* discovery.refresh();
450
455
  });
456
+ const refreshIn = (directory) => Effect.gen(function* () {
457
+ const target = resolve(directory);
458
+ for (const key of [...cache.keys()]) if (target === key || target.startsWith(key + sep)) cache.delete(key);
459
+ yield* Effect.ignore(discovery.refreshIn(target));
460
+ });
451
461
  return {
452
462
  inspect,
453
463
  classify,
454
- refresh
464
+ refresh,
465
+ refreshIn
455
466
  };
456
467
  }
457
468
  /**
469
+ * Known template-mirror pairs (#290): repo files generated from (or kept in
470
+ * byte-lockstep with) a template a package ships, keyed by projectDir-relative
471
+ * POSIX path. An unmapped diff on one of these is a mirror of an already
472
+ * attributed template change, not an unattributed change needing its own
473
+ * changeset — the hint spares the agent a manual diff. Deliberately a small
474
+ * static table, not a diff-correlation engine.
475
+ */
476
+ const TEMPLATE_MIRRORS = { [MARKDOWNLINT_CONFIG_PATH]: "mirrors the @savvy-web/silk-effects markdownlint template (src/lint/cli/templates/markdownlint.gen.ts)" };
477
+ /**
478
+ * A machine-readable hint for a path that mapped to NO package (#290): a
479
+ * versionFiles / additionalScopes glob that names the path without a
480
+ * materialized file behind it (the deleted-file shape branch diffs produce),
481
+ * or a known template mirror. Pure — pattern matching only, no filesystem.
482
+ * `null` when nothing explains the path.
483
+ */
484
+ function unmappedHint(inspected, rel) {
485
+ for (const s of inspected.packages) for (const vf of s.versionFiles) if (globMatchesRel(vf.glob, rel)) return `versionFiles of "${s.name}" (glob "${vf.glob}")`;
486
+ for (const s of inspected.packages) {
487
+ const glob = s.additionalScopes.find((g) => globMatchesRel(g, rel));
488
+ if (glob !== void 0) return `additionalScopes of "${s.name}" (glob "${glob}")`;
489
+ }
490
+ return Object.hasOwn(TEMPLATE_MIRRORS, rel) ? TEMPLATE_MIRRORS[rel] : null;
491
+ }
492
+ /**
458
493
  * Classify a single path against an inspected config.
459
494
  */
460
495
  function classifyOne(inspected, path) {
@@ -504,6 +539,17 @@ function classifyOne(inspected, path) {
504
539
  package: rootScope,
505
540
  reason: "workspace"
506
541
  };
542
+ if (isInside(inspected.projectDir, abs)) {
543
+ const hint = unmappedHint(inspected, relative(inspected.projectDir, abs).replaceAll("\\", "/"));
544
+ if (hint !== null) return {
545
+ path,
546
+ package: null,
547
+ reason: {
548
+ kind: "unmappedHint",
549
+ hint
550
+ }
551
+ };
552
+ }
507
553
  return {
508
554
  path,
509
555
  package: null,
@@ -524,7 +570,8 @@ function makeConfigInspectorTest(fixed) {
524
570
  return Layer.succeed(ConfigInspector, {
525
571
  inspect: () => Effect.succeed(fixed),
526
572
  classify: (_cwd, paths) => Effect.succeed(paths.map((p) => classifyOne(fixed, p))),
527
- refresh: () => Effect.void
573
+ refresh: () => Effect.void,
574
+ refreshIn: () => Effect.void
528
575
  });
529
576
  }
530
577