@descent-vtt/spec-graph 0.9.9 → 0.10.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.
Files changed (43) hide show
  1. package/CHANGELOG.md +132 -2
  2. package/README.md +523 -308
  3. package/dist/cli.d.ts +2 -2
  4. package/dist/cli.js +112 -67
  5. package/dist/cli.js.map +1 -1
  6. package/dist/extract.d.ts +14 -1
  7. package/dist/extract.js +114 -10
  8. package/dist/extract.js.map +1 -1
  9. package/dist/glob.d.ts +17 -2
  10. package/dist/glob.js +137 -34
  11. package/dist/glob.js.map +1 -1
  12. package/dist/index.d.ts +1 -1
  13. package/dist/index.js +1 -1
  14. package/dist/index.js.map +1 -1
  15. package/dist/lifecycle.d.ts +11 -1
  16. package/dist/lifecycle.js +187 -28
  17. package/dist/lifecycle.js.map +1 -1
  18. package/dist/report.d.ts +22 -0
  19. package/dist/report.js +50 -3
  20. package/dist/report.js.map +1 -1
  21. package/dist/sections.d.ts +7 -1
  22. package/dist/sections.js +10 -2
  23. package/dist/sections.js.map +1 -1
  24. package/dist/state.js +19 -3
  25. package/dist/state.js.map +1 -1
  26. package/dist/vendor/spec-core/markdown/frontmatter.d.ts +11 -1
  27. package/dist/vendor/spec-core/markdown/frontmatter.js +20 -3
  28. package/dist/vendor/spec-core/markdown/frontmatter.js.map +1 -1
  29. package/dist/vendor/spec-core/pattern/glob.d.ts +88 -2
  30. package/dist/vendor/spec-core/pattern/glob.js +234 -32
  31. package/dist/vendor/spec-core/pattern/glob.js.map +1 -1
  32. package/dist/vendor/spec-core/pattern/index.d.ts +2 -2
  33. package/dist/vendor/spec-core/pattern/index.js +1 -1
  34. package/dist/vendor/spec-core/pattern/index.js.map +1 -1
  35. package/dist/vendor/spec-core/text/index.d.ts +1 -0
  36. package/dist/vendor/spec-core/text/index.js +1 -0
  37. package/dist/vendor/spec-core/text/index.js.map +1 -1
  38. package/dist/vendor/spec-core/text/width.d.ts +30 -0
  39. package/dist/vendor/spec-core/text/width.js +113 -0
  40. package/dist/vendor/spec-core/text/width.js.map +1 -0
  41. package/dist/yaml.js +6 -2
  42. package/dist/yaml.js.map +1 -1
  43. package/package.json +1 -1
package/dist/lifecycle.js CHANGED
@@ -22,11 +22,42 @@
22
22
  * A hyphenated term is found anywhere in the status, so a row needs no term
23
23
  * another in it already finds: `replaced` reads "replaced by", `review` reads
24
24
  * "in review" and "under review", and `in-progress` reads "work in progress".
25
+ *
26
+ * A Chinese term is the English word it translates, in Traditional and in
27
+ * Simplified characters, and sits in that word's row. Chinese writes no space
28
+ * between words, so a term in Han characters is found anywhere in the status,
29
+ * as a hyphenated one is, and the same economy applies: `棄用` reads `已棄用`,
30
+ * and `待審` reads `待審核`. The superseded forms are rules of their own, in
31
+ * {@link supersededInChinese}.
25
32
  */
26
33
  const VOCABULARY = [
27
34
  [
28
35
  'retired',
29
36
  [
37
+ // Read as itself, though it starts with a negation. The active row's
38
+ // `採納` inside it is not read, because that negation stands before it.
39
+ '不採納',
40
+ '不采纳',
41
+ '棄用',
42
+ '弃用',
43
+ '廢棄',
44
+ '废弃',
45
+ '已停用',
46
+ '已過時',
47
+ '已过时',
48
+ '否決',
49
+ '否决',
50
+ '已拒絕',
51
+ '已拒绝',
52
+ '撤回',
53
+ '作廢',
54
+ '作废',
55
+ '延後',
56
+ '延后',
57
+ '暫緩',
58
+ '暂缓',
59
+ '擱置',
60
+ '搁置',
30
61
  'superseded',
31
62
  // Misspelled in the wild often enough that omitting it would silently
32
63
  // mis-classify real documents as active.
@@ -67,18 +98,42 @@ const VOCABULARY = [
67
98
  // have to go somewhere (ADR-0011). After retirement, so a brief written
68
99
  // `archived, superseded by B-0007` is still retired.
69
100
  'record',
70
- ['archived', 'archive'],
101
+ ['archived', 'archive', '封存', '歸檔', '归档'],
71
102
  ],
72
103
  [
73
104
  'frozen',
74
- ['final', 'finalised', 'finalized', 'frozen', 'locked', 'ratified', 'immutable', 'sealed', 'standard', 'published'],
105
+ [
106
+ 'final',
107
+ 'finalised',
108
+ 'finalized',
109
+ 'frozen',
110
+ 'locked',
111
+ 'ratified',
112
+ 'immutable',
113
+ 'sealed',
114
+ 'standard',
115
+ 'published',
116
+ '定案',
117
+ '已凍結',
118
+ '已冻结',
119
+ ],
75
120
  ],
76
121
  // Ahead of `active`, whose `accepted` it contains: read after it, as the
77
- // rest of the draft row is, `provisionally accepted` would be active.
78
- ['draft', ['provisionally-accepted']],
122
+ // rest of the draft row is, `provisionally accepted` would be active. `暫定`
123
+ // is the same word, and `暫定接受` holds the active row's `接受`.
124
+ ['draft', ['provisionally-accepted', '暫定', '暂定']],
79
125
  [
80
126
  'active',
81
127
  [
128
+ '接受',
129
+ '採納',
130
+ '采纳',
131
+ '核准',
132
+ '批准',
133
+ '生效',
134
+ '已實施',
135
+ '已实施',
136
+ '已完成',
82
137
  'accepted',
83
138
  'active',
84
139
  'approved',
@@ -106,6 +161,19 @@ const VOCABULARY = [
106
161
  [
107
162
  'draft',
108
163
  [
164
+ '草稿',
165
+ '草案',
166
+ '提議',
167
+ '提议',
168
+ '提案',
169
+ '審查中',
170
+ '审查中',
171
+ '審核中',
172
+ '审核中',
173
+ '討論中',
174
+ '讨论中',
175
+ '待審',
176
+ '待审',
109
177
  'draft',
110
178
  'drafting',
111
179
  'proposed',
@@ -148,18 +216,50 @@ const RETIRING_DIRECTORIES = new Set([
148
216
  'superceded',
149
217
  'withdrawn',
150
218
  ]);
151
- /** Front-matter keys that may carry a status, in order of preference. */
152
- export const STATUS_KEYS = ['status', 'state', 'stage', 'lifecycle', 'phase', 'adr-status'];
219
+ /**
220
+ * Front-matter keys that may carry a status, in order of preference. The
221
+ * Chinese key is the English one it translates, so it ranks beside it.
222
+ */
223
+ export const STATUS_KEYS = ['status', '狀態', '状态', 'state', 'stage', 'lifecycle', 'phase', 'adr-status'];
153
224
  /** Heading names whose body declares the document status. */
154
- const STATUS_HEADINGS = new Set(['status', 'state', 'stage', 'lifecycle', 'current status']);
225
+ const STATUS_HEADINGS = new Set([
226
+ 'status',
227
+ 'state',
228
+ 'stage',
229
+ 'lifecycle',
230
+ 'current status',
231
+ '狀態',
232
+ '状态',
233
+ ]);
234
+ /** A term written in Han characters, which is found anywhere in a status rather than as a word. */
235
+ const HAN = /\p{Script=Han}/u;
155
236
  /**
156
- * Reduces a status string to lookup form.
157
- *
158
- * Strips Markdown emphasis, badge syntax, emoji and dates, so `**Accepted**`,
159
- * `` `accepted` ``, `Accepted ✅` and `Accepted (2026-03-01)` all land on the
160
- * same token.
237
+ * What cancels a Han term written directly after it: `未接受`, `尚未核准` and
238
+ * `不再生效` are not accepted, approved or in effect, and `未移交給 ADR-0002`
239
+ * hands nothing on. One list for status words and relation phrases alike, so
240
+ * the two cannot come to disagree about what a negation is. `尚未` ends in
241
+ * `未`, so it needs no entry of its own.
161
242
  */
162
- export function normaliseStatus(raw) {
243
+ const HAN_NEGATIONS = ['不', '未', '非', '沒', '没', '無', '无', '勿', '不再'];
244
+ /**
245
+ * `被 ADR-0003 取代`, and `被取代` with nothing between: superseded, in the
246
+ * passive, with the successor between `被` and the verb. Thirty characters
247
+ * leave room for a name or two and not for a clause, and the gap never runs
248
+ * past the end of a sentence: `被團隊接受。取代 ADR-0002 的方案另議` is no
249
+ * supersession of this document.
250
+ */
251
+ const PASSIVE_SUPERSESSION = /被([^。;!?]{0,30}?)所?(?:取而代之|取代|替代)/gu;
252
+ /**
253
+ * `已取代` said alone. Followed by a name - `已取代 ADR-0002`, `已取代舊方案`,
254
+ * after spaces or a colon - it usually says what *this* document replaced, and
255
+ * reading it either way would be a guess.
256
+ */
257
+ const SUPERSEDED_ALONE = /已取代(?![\s::]*[\p{L}\p{N}])/gu;
258
+ /**
259
+ * Strips what decorates a status and keeps its punctuation: HTML, emphasis,
260
+ * link syntax, emoji and dates. Lower-cased, since every term is.
261
+ */
262
+ function undecorated(raw) {
163
263
  // An underscore between two letters joins two words - `not_planned`,
164
264
  // `in_progress` - where one at either end of a word is emphasis. Stripped
165
265
  // with the emphasis, it ran the two words into one that is neither.
@@ -170,10 +270,52 @@ export function normaliseStatus(raw) {
170
270
  .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
171
271
  .replace(/\p{Extended_Pictographic}/gu, ' ')
172
272
  .replace(/\d{4}-\d{2}-\d{2}/g, ' ')
173
- .toLowerCase()
273
+ .toLowerCase();
274
+ }
275
+ /**
276
+ * Reduces a status string to lookup form.
277
+ *
278
+ * Strips Markdown emphasis, badge syntax, emoji and dates, so `**Accepted**`,
279
+ * `` `accepted` ``, `Accepted ✅` and `Accepted (2026-03-01)` all land on the
280
+ * same token.
281
+ */
282
+ export function normaliseStatus(raw) {
283
+ return undecorated(raw)
174
284
  .replace(/[^\p{L}\p{N}]+/gu, ' ')
175
285
  .trim();
176
286
  }
287
+ /** Whether a Chinese negation stands directly before the offset. */
288
+ export function negatedAt(text, offset) {
289
+ const before = text.slice(0, offset);
290
+ return HAN_NEGATIONS.some((negation) => before.endsWith(negation));
291
+ }
292
+ /** Whether a Han term occurs in the text with no negation directly before it. */
293
+ function hanTermIn(text, term) {
294
+ for (let at = text.indexOf(term); at !== -1; at = text.indexOf(term, at + 1)) {
295
+ if (!negatedAt(text, at))
296
+ return true;
297
+ }
298
+ return false;
299
+ }
300
+ /** The text between `被` and the verb of every passive supersession not negated. */
301
+ function passiveSupersessions(text) {
302
+ const out = [];
303
+ for (const match of text.matchAll(PASSIVE_SUPERSESSION)) {
304
+ if (!negatedAt(text, match.index))
305
+ out.push(match[1]);
306
+ }
307
+ return out;
308
+ }
309
+ /** Whether a status says it was superseded, in Chinese. */
310
+ function supersededInChinese(text) {
311
+ if (passiveSupersessions(text).length > 0)
312
+ return true;
313
+ for (const match of text.matchAll(SUPERSEDED_ALONE)) {
314
+ if (!negatedAt(text, match.index))
315
+ return true;
316
+ }
317
+ return false;
318
+ }
177
319
  /**
178
320
  * Maps a raw status string onto a lifecycle phase.
179
321
  *
@@ -185,12 +327,21 @@ export function normaliseStatus(raw) {
185
327
  export function phaseOf(raw) {
186
328
  if (raw === null || raw === undefined)
187
329
  return 'unknown';
188
- const text = normaliseStatus(raw);
330
+ const plain = undecorated(raw);
331
+ // Retirement wins wherever it is written, so the superseded forms need no
332
+ // place in the table's order.
333
+ if (supersededInChinese(plain))
334
+ return 'retired';
335
+ const text = plain.replace(/[^\p{L}\p{N}]+/gu, ' ').trim();
189
336
  const words = new Set(text.split(' '));
190
337
  const hyphenated = text.replace(/ /g, '-');
191
338
  for (const [phase, terms] of VOCABULARY) {
192
339
  for (const term of terms) {
193
- if (term.includes('-')) {
340
+ if (HAN.test(term)) {
341
+ if (hanTermIn(plain, term))
342
+ return phase;
343
+ }
344
+ else if (term.includes('-')) {
194
345
  if (hyphenated.includes(term))
195
346
  return phase;
196
347
  }
@@ -223,7 +374,7 @@ export function isRetired(phase) {
223
374
  }
224
375
  /** True when the heading names a section that declares the document status. */
225
376
  export function isStatusHeading(text) {
226
- return STATUS_HEADINGS.has(text.toLowerCase().replace(/[:*_`]/g, '').trim());
377
+ return STATUS_HEADINGS.has(text.toLowerCase().replace(/[::*_`]/g, '').trim());
227
378
  }
228
379
  /**
229
380
  * Infers retirement from where a file lives.
@@ -249,23 +400,31 @@ export function phaseFromPath(posixPath) {
249
400
  * recorded, and it is written in the status field rather than as a link. Reading
250
401
  * it here means the relation reaches the graph even in repositories that never
251
402
  * adopted an explicit field for it.
403
+ *
404
+ * In Chinese the successor stands between `被` and the verb: `已被 ADR-0003
405
+ * 取代`. Left unread, a document that said so and one that said it supersedes
406
+ * this one disagreed, and `unreciprocated-supersession` asked for a
407
+ * `superseded-by` key the status had already given.
252
408
  */
253
409
  export function supersessionTargetsIn(raw) {
254
410
  const out = [];
411
+ const plain = raw.replace(/[`*_]/g, '');
255
412
  // The tail runs to the end of the line, not of the string: a status that
256
413
  // wraps onto a second line still names its successor on the first.
257
414
  const pattern = /(?:super[sc]eded|replaced|obsoleted)\s*(?:by|with|through)?\s*[:\-–—]?\s*(.+)/i;
258
- const match = pattern.exec(raw.replace(/[`*_]/g, ''));
259
- if (!match)
260
- return out;
261
- const tail = match[1];
262
- for (const token of tail.split(/[,;]|\band\b/)) {
263
- const cleaned = token
264
- .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
265
- .replace(/[()<>"']/g, ' ')
266
- .trim();
267
- if (cleaned.length > 0 && cleaned.length < 200)
268
- out.push(cleaned);
415
+ const match = pattern.exec(plain);
416
+ // A link inside the passive is read by its label, as a status is: its path
417
+ // would run the name past the thirty characters the form allows.
418
+ const tails = match ? [match[1]] : passiveSupersessions(plain.replace(/\[([^\]]*)\]\([^)]*\)/g, '$1'));
419
+ for (const tail of tails) {
420
+ for (const token of tail.split(/[,;、,;和及與与]|\band\b/)) {
421
+ const cleaned = token
422
+ .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
423
+ .replace(/[()<>"'()]/g, ' ')
424
+ .trim();
425
+ if (cleaned.length > 0 && cleaned.length < 200)
426
+ out.push(cleaned);
427
+ }
269
428
  }
270
429
  return out;
271
430
  }
@@ -1 +1 @@
1
- {"version":3,"file":"lifecycle.js","sourceRoot":"","sources":["../src/lifecycle.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Status vocabulary normalisation.\n *\n * Every specification ecosystem invented its own status words, and no two agree.\n * MADR says `accepted`; Kubernetes KEPs say `implementable`; Rust RFCs say\n * `merged`; a hundred internal wikis say `Live`, `Signed off`, or `DONE ✅`.\n *\n * spec-graph refuses to care. What it needs from a status is one bit - *can this\n * document still absorb an obligation?* - and that bit is recoverable from all\n * of those vocabularies at once. The tables below are the only place in the\n * codebase that knows any ecosystem-specific word, which is what keeps the\n * engine domain-agnostic: supporting a new convention is a table entry, never a\n * rule change.\n */\n\nimport type { Phase, Receptivity } from './types.js';\n\n/**\n * Recognised status words, most terminal first.\n *\n * Order matters. A status of \"accepted, superseded by ADR-0009\" is retired, not\n * active: retirement is terminal, so a retirement word anywhere in the string\n * wins over anything else in it.\n *\n * A hyphenated term is found anywhere in the status, so a row needs no term\n * another in it already finds: `replaced` reads \"replaced by\", `review` reads\n * \"in review\" and \"under review\", and `in-progress` reads \"work in progress\".\n */\nconst VOCABULARY: readonly (readonly [Phase, readonly string[]])[] = [\n [\n 'retired',\n [\n 'superseded',\n // Misspelled in the wild often enough that omitting it would silently\n // mis-classify real documents as active.\n 'superceded',\n 'supersedes-by',\n 'replaced',\n 'deprecated',\n 'obsolete',\n 'obsoleted',\n 'retired',\n 'rejected',\n 'declined',\n 'withdrawn',\n 'abandoned',\n 'cancelled',\n 'canceled',\n 'dropped',\n 'revoked',\n 'reverted',\n 'moved',\n 'historical',\n 'defunct',\n 'inactive',\n 'dead',\n 'void',\n 'closed',\n 'postponed',\n 'deferred',\n 'not-planned',\n 'wontfix',\n ],\n ],\n [\n // A finished round of work kept as it was left: spec-brief archives a brief\n // by writing this word and moving the file. Every spec-* tool reads it as a\n // record rather than as a retired decision - depending on one is normal,\n // its unticked boxes were never going to be ticked, and its links still\n // have to go somewhere (ADR-0011). After retirement, so a brief written\n // `archived, superseded by B-0007` is still retired.\n 'record',\n ['archived', 'archive'],\n ],\n [\n 'frozen',\n ['final', 'finalised', 'finalized', 'frozen', 'locked', 'ratified', 'immutable', 'sealed', 'standard', 'published'],\n ],\n // Ahead of `active`, whose `accepted` it contains: read after it, as the\n // rest of the draft row is, `provisionally accepted` would be active.\n ['draft', ['provisionally-accepted']],\n [\n 'active',\n [\n 'accepted',\n 'active',\n 'approved',\n 'adopted',\n 'agreed',\n 'implementable',\n 'implemented',\n 'implementing',\n 'current',\n 'effective',\n 'in-effect',\n 'enforced',\n 'stable',\n 'merged',\n 'released',\n 'shipped',\n 'live',\n 'done',\n 'complete',\n 'completed',\n 'signed-off',\n 'committed',\n ],\n ],\n [\n 'draft',\n [\n 'draft',\n 'drafting',\n 'proposed',\n 'proposal',\n 'provisional',\n 'prospective',\n 'wip',\n 'in-progress',\n 'review',\n 'reviewing',\n 'discussion',\n 'discussing',\n 'pending',\n 'idea',\n 'exploratory',\n 'candidate',\n 'open',\n 'new',\n 'unreviewed',\n 'rfc',\n 'experimental',\n 'alpha',\n 'beta',\n 'incubating',\n ],\n ],\n];\n\n/** Path segments that retire every document beneath them. */\nconst RETIRING_DIRECTORIES: ReadonlySet<string> = new Set([\n 'archive',\n 'archived',\n 'attic',\n 'deprecated',\n 'graveyard',\n 'historical',\n 'obsolete',\n 'rejected',\n 'retired',\n 'superseded',\n 'superceded',\n 'withdrawn',\n]);\n\n/** Front-matter keys that may carry a status, in order of preference. */\nexport const STATUS_KEYS: readonly string[] = ['status', 'state', 'stage', 'lifecycle', 'phase', 'adr-status'];\n\n/** Heading names whose body declares the document status. */\nconst STATUS_HEADINGS: ReadonlySet<string> = new Set(['status', 'state', 'stage', 'lifecycle', 'current status']);\n\n/**\n * Reduces a status string to lookup form.\n *\n * Strips Markdown emphasis, badge syntax, emoji and dates, so `**Accepted**`,\n * `` `accepted` ``, `Accepted ✅` and `Accepted (2026-03-01)` all land on the\n * same token.\n */\nexport function normaliseStatus(raw: string): string {\n // An underscore between two letters joins two words - `not_planned`,\n // `in_progress` - where one at either end of a word is emphasis. Stripped\n // with the emphasis, it ran the two words into one that is neither.\n return raw\n .replace(/<[^>]*>/g, ' ')\n .replace(/(?<=[\\p{L}\\p{N}])_(?=[\\p{L}\\p{N}])/gu, ' ')\n .replace(/[`*_~\"'#]/g, '')\n .replace(/\\[([^\\]]*)\\]\\([^)]*\\)/g, '$1')\n .replace(/\\p{Extended_Pictographic}/gu, ' ')\n .replace(/\\d{4}-\\d{2}-\\d{2}/g, ' ')\n .toLowerCase()\n .replace(/[^\\p{L}\\p{N}]+/gu, ' ')\n .trim();\n}\n\n/**\n * Maps a raw status string onto a lifecycle phase.\n *\n * Returns `unknown` rather than guessing when nothing matches: a document with\n * an unrecognised status is not evidence of anything, and inventing a phase for\n * it would produce exactly the confident-but-wrong findings this tool exists to\n * eliminate.\n */\nexport function phaseOf(raw: string | null | undefined): Phase {\n if (raw === null || raw === undefined) return 'unknown';\n const text = normaliseStatus(raw);\n const words = new Set(text.split(' '));\n const hyphenated = text.replace(/ /g, '-');\n\n for (const [phase, terms] of VOCABULARY) {\n for (const term of terms) {\n if (term.includes('-')) {\n if (hyphenated.includes(term)) return phase;\n } else if (words.has(term)) {\n return phase;\n }\n }\n }\n return 'unknown';\n}\n\n/** Whether a document in this phase can take on a new open obligation. */\nexport function receptivityOf(phase: Phase): Receptivity {\n switch (phase) {\n case 'draft':\n case 'active':\n return 'receptive';\n case 'frozen':\n case 'retired':\n // A record was never asking for an obligation, so it cannot take one on.\n // Sealed for the same reason a retired document is, by a different route.\n case 'record':\n return 'sealed';\n case 'unknown':\n return 'unknown';\n }\n}\n\n/** True when the phase means the document no longer binds anybody. */\nexport function isRetired(phase: Phase): boolean {\n return phase === 'retired';\n}\n\n/** True when the heading names a section that declares the document status. */\nexport function isStatusHeading(text: string): boolean {\n return STATUS_HEADINGS.has(text.toLowerCase().replace(/[:*_`]/g, '').trim());\n}\n\n/**\n * Infers retirement from where a file lives.\n *\n * Moving an ADR into `docs/adr/archive/` is the most common way a team retires a\n * decision without touching its front matter, and a graph that misses it will\n * happily let obligations be delegated into the archive.\n */\nexport function phaseFromPath(posixPath: string): Phase {\n const segments = posixPath.toLowerCase().split('/');\n // The file name itself is not a directory; a document called `archive`, with\n // or without an extension, is not retired.\n for (let i = 0; i < segments.length - 1; i += 1) {\n if (RETIRING_DIRECTORIES.has(segments[i] as string)) return 'retired';\n }\n return 'unknown';\n}\n\n/**\n * Pulls a supersession target out of a status string.\n *\n * `Superseded by ADR-0009` is the single most common way a supersession is ever\n * recorded, and it is written in the status field rather than as a link. Reading\n * it here means the relation reaches the graph even in repositories that never\n * adopted an explicit field for it.\n */\nexport function supersessionTargetsIn(raw: string): string[] {\n const out: string[] = [];\n // The tail runs to the end of the line, not of the string: a status that\n // wraps onto a second line still names its successor on the first.\n const pattern = /(?:super[sc]eded|replaced|obsoleted)\\s*(?:by|with|through)?\\s*[:\\-–—]?\\s*(.+)/i;\n const match = pattern.exec(raw.replace(/[`*_]/g, ''));\n if (!match) return out;\n const tail = match[1] as string;\n for (const token of tail.split(/[,;]|\\band\\b/)) {\n const cleaned = token\n .replace(/\\[([^\\]]*)\\]\\([^)]*\\)/g, '$1')\n .replace(/[()<>\"']/g, ' ')\n .trim();\n if (cleaned.length > 0 && cleaned.length < 200) out.push(cleaned);\n }\n return out;\n}\n"]}
1
+ {"version":3,"file":"lifecycle.js","sourceRoot":"","sources":["../src/lifecycle.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Status vocabulary normalisation.\n *\n * Every specification ecosystem invented its own status words, and no two agree.\n * MADR says `accepted`; Kubernetes KEPs say `implementable`; Rust RFCs say\n * `merged`; a hundred internal wikis say `Live`, `Signed off`, or `DONE ✅`.\n *\n * spec-graph refuses to care. What it needs from a status is one bit - *can this\n * document still absorb an obligation?* - and that bit is recoverable from all\n * of those vocabularies at once. The tables below are the only place in the\n * codebase that knows any ecosystem-specific word, which is what keeps the\n * engine domain-agnostic: supporting a new convention is a table entry, never a\n * rule change.\n */\n\nimport type { Phase, Receptivity } from './types.js';\n\n/**\n * Recognised status words, most terminal first.\n *\n * Order matters. A status of \"accepted, superseded by ADR-0009\" is retired, not\n * active: retirement is terminal, so a retirement word anywhere in the string\n * wins over anything else in it.\n *\n * A hyphenated term is found anywhere in the status, so a row needs no term\n * another in it already finds: `replaced` reads \"replaced by\", `review` reads\n * \"in review\" and \"under review\", and `in-progress` reads \"work in progress\".\n *\n * A Chinese term is the English word it translates, in Traditional and in\n * Simplified characters, and sits in that word's row. Chinese writes no space\n * between words, so a term in Han characters is found anywhere in the status,\n * as a hyphenated one is, and the same economy applies: `棄用` reads `已棄用`,\n * and `待審` reads `待審核`. The superseded forms are rules of their own, in\n * {@link supersededInChinese}.\n */\nconst VOCABULARY: readonly (readonly [Phase, readonly string[]])[] = [\n [\n 'retired',\n [\n // Read as itself, though it starts with a negation. The active row's\n // `採納` inside it is not read, because that negation stands before it.\n '不採納',\n '不采纳',\n '棄用',\n '弃用',\n '廢棄',\n '废弃',\n '已停用',\n '已過時',\n '已过时',\n '否決',\n '否决',\n '已拒絕',\n '已拒绝',\n '撤回',\n '作廢',\n '作废',\n '延後',\n '延后',\n '暫緩',\n '暂缓',\n '擱置',\n '搁置',\n 'superseded',\n // Misspelled in the wild often enough that omitting it would silently\n // mis-classify real documents as active.\n 'superceded',\n 'supersedes-by',\n 'replaced',\n 'deprecated',\n 'obsolete',\n 'obsoleted',\n 'retired',\n 'rejected',\n 'declined',\n 'withdrawn',\n 'abandoned',\n 'cancelled',\n 'canceled',\n 'dropped',\n 'revoked',\n 'reverted',\n 'moved',\n 'historical',\n 'defunct',\n 'inactive',\n 'dead',\n 'void',\n 'closed',\n 'postponed',\n 'deferred',\n 'not-planned',\n 'wontfix',\n ],\n ],\n [\n // A finished round of work kept as it was left: spec-brief archives a brief\n // by writing this word and moving the file. Every spec-* tool reads it as a\n // record rather than as a retired decision - depending on one is normal,\n // its unticked boxes were never going to be ticked, and its links still\n // have to go somewhere (ADR-0011). After retirement, so a brief written\n // `archived, superseded by B-0007` is still retired.\n 'record',\n ['archived', 'archive', '封存', '歸檔', '归档'],\n ],\n [\n 'frozen',\n [\n 'final',\n 'finalised',\n 'finalized',\n 'frozen',\n 'locked',\n 'ratified',\n 'immutable',\n 'sealed',\n 'standard',\n 'published',\n '定案',\n '已凍結',\n '已冻结',\n ],\n ],\n // Ahead of `active`, whose `accepted` it contains: read after it, as the\n // rest of the draft row is, `provisionally accepted` would be active. `暫定`\n // is the same word, and `暫定接受` holds the active row's `接受`.\n ['draft', ['provisionally-accepted', '暫定', '暂定']],\n [\n 'active',\n [\n '接受',\n '採納',\n '采纳',\n '核准',\n '批准',\n '生效',\n '已實施',\n '已实施',\n '已完成',\n 'accepted',\n 'active',\n 'approved',\n 'adopted',\n 'agreed',\n 'implementable',\n 'implemented',\n 'implementing',\n 'current',\n 'effective',\n 'in-effect',\n 'enforced',\n 'stable',\n 'merged',\n 'released',\n 'shipped',\n 'live',\n 'done',\n 'complete',\n 'completed',\n 'signed-off',\n 'committed',\n ],\n ],\n [\n 'draft',\n [\n '草稿',\n '草案',\n '提議',\n '提议',\n '提案',\n '審查中',\n '审查中',\n '審核中',\n '审核中',\n '討論中',\n '讨论中',\n '待審',\n '待审',\n 'draft',\n 'drafting',\n 'proposed',\n 'proposal',\n 'provisional',\n 'prospective',\n 'wip',\n 'in-progress',\n 'review',\n 'reviewing',\n 'discussion',\n 'discussing',\n 'pending',\n 'idea',\n 'exploratory',\n 'candidate',\n 'open',\n 'new',\n 'unreviewed',\n 'rfc',\n 'experimental',\n 'alpha',\n 'beta',\n 'incubating',\n ],\n ],\n];\n\n/** Path segments that retire every document beneath them. */\nconst RETIRING_DIRECTORIES: ReadonlySet<string> = new Set([\n 'archive',\n 'archived',\n 'attic',\n 'deprecated',\n 'graveyard',\n 'historical',\n 'obsolete',\n 'rejected',\n 'retired',\n 'superseded',\n 'superceded',\n 'withdrawn',\n]);\n\n/**\n * Front-matter keys that may carry a status, in order of preference. The\n * Chinese key is the English one it translates, so it ranks beside it.\n */\nexport const STATUS_KEYS: readonly string[] = ['status', '狀態', '状态', 'state', 'stage', 'lifecycle', 'phase', 'adr-status'];\n\n/** Heading names whose body declares the document status. */\nconst STATUS_HEADINGS: ReadonlySet<string> = new Set([\n 'status',\n 'state',\n 'stage',\n 'lifecycle',\n 'current status',\n '狀態',\n '状态',\n]);\n\n/** A term written in Han characters, which is found anywhere in a status rather than as a word. */\nconst HAN = /\\p{Script=Han}/u;\n\n/**\n * What cancels a Han term written directly after it: `未接受`, `尚未核准` and\n * `不再生效` are not accepted, approved or in effect, and `未移交給 ADR-0002`\n * hands nothing on. One list for status words and relation phrases alike, so\n * the two cannot come to disagree about what a negation is. `尚未` ends in\n * `未`, so it needs no entry of its own.\n */\nconst HAN_NEGATIONS: readonly string[] = ['不', '未', '非', '沒', '没', '無', '无', '勿', '不再'];\n\n/**\n * `被 ADR-0003 取代`, and `被取代` with nothing between: superseded, in the\n * passive, with the successor between `被` and the verb. Thirty characters\n * leave room for a name or two and not for a clause, and the gap never runs\n * past the end of a sentence: `被團隊接受。取代 ADR-0002 的方案另議` is no\n * supersession of this document.\n */\nconst PASSIVE_SUPERSESSION = /被([^。;!?]{0,30}?)所?(?:取而代之|取代|替代)/gu;\n\n/**\n * `已取代` said alone. Followed by a name - `已取代 ADR-0002`, `已取代舊方案`,\n * after spaces or a colon - it usually says what *this* document replaced, and\n * reading it either way would be a guess.\n */\nconst SUPERSEDED_ALONE = /已取代(?![\\s::]*[\\p{L}\\p{N}])/gu;\n\n/**\n * Strips what decorates a status and keeps its punctuation: HTML, emphasis,\n * link syntax, emoji and dates. Lower-cased, since every term is.\n */\nfunction undecorated(raw: string): string {\n // An underscore between two letters joins two words - `not_planned`,\n // `in_progress` - where one at either end of a word is emphasis. Stripped\n // with the emphasis, it ran the two words into one that is neither.\n return raw\n .replace(/<[^>]*>/g, ' ')\n .replace(/(?<=[\\p{L}\\p{N}])_(?=[\\p{L}\\p{N}])/gu, ' ')\n .replace(/[`*_~\"'#]/g, '')\n .replace(/\\[([^\\]]*)\\]\\([^)]*\\)/g, '$1')\n .replace(/\\p{Extended_Pictographic}/gu, ' ')\n .replace(/\\d{4}-\\d{2}-\\d{2}/g, ' ')\n .toLowerCase();\n}\n\n/**\n * Reduces a status string to lookup form.\n *\n * Strips Markdown emphasis, badge syntax, emoji and dates, so `**Accepted**`,\n * `` `accepted` ``, `Accepted ✅` and `Accepted (2026-03-01)` all land on the\n * same token.\n */\nexport function normaliseStatus(raw: string): string {\n return undecorated(raw)\n .replace(/[^\\p{L}\\p{N}]+/gu, ' ')\n .trim();\n}\n\n/** Whether a Chinese negation stands directly before the offset. */\nexport function negatedAt(text: string, offset: number): boolean {\n const before = text.slice(0, offset);\n return HAN_NEGATIONS.some((negation) => before.endsWith(negation));\n}\n\n/** Whether a Han term occurs in the text with no negation directly before it. */\nfunction hanTermIn(text: string, term: string): boolean {\n for (let at = text.indexOf(term); at !== -1; at = text.indexOf(term, at + 1)) {\n if (!negatedAt(text, at)) return true;\n }\n return false;\n}\n\n/** The text between `被` and the verb of every passive supersession not negated. */\nfunction passiveSupersessions(text: string): string[] {\n const out: string[] = [];\n for (const match of text.matchAll(PASSIVE_SUPERSESSION)) {\n if (!negatedAt(text, match.index)) out.push(match[1] as string);\n }\n return out;\n}\n\n/** Whether a status says it was superseded, in Chinese. */\nfunction supersededInChinese(text: string): boolean {\n if (passiveSupersessions(text).length > 0) return true;\n for (const match of text.matchAll(SUPERSEDED_ALONE)) {\n if (!negatedAt(text, match.index)) return true;\n }\n return false;\n}\n\n/**\n * Maps a raw status string onto a lifecycle phase.\n *\n * Returns `unknown` rather than guessing when nothing matches: a document with\n * an unrecognised status is not evidence of anything, and inventing a phase for\n * it would produce exactly the confident-but-wrong findings this tool exists to\n * eliminate.\n */\nexport function phaseOf(raw: string | null | undefined): Phase {\n if (raw === null || raw === undefined) return 'unknown';\n const plain = undecorated(raw);\n // Retirement wins wherever it is written, so the superseded forms need no\n // place in the table's order.\n if (supersededInChinese(plain)) return 'retired';\n const text = plain.replace(/[^\\p{L}\\p{N}]+/gu, ' ').trim();\n const words = new Set(text.split(' '));\n const hyphenated = text.replace(/ /g, '-');\n\n for (const [phase, terms] of VOCABULARY) {\n for (const term of terms) {\n if (HAN.test(term)) {\n if (hanTermIn(plain, term)) return phase;\n } else if (term.includes('-')) {\n if (hyphenated.includes(term)) return phase;\n } else if (words.has(term)) {\n return phase;\n }\n }\n }\n return 'unknown';\n}\n\n/** Whether a document in this phase can take on a new open obligation. */\nexport function receptivityOf(phase: Phase): Receptivity {\n switch (phase) {\n case 'draft':\n case 'active':\n return 'receptive';\n case 'frozen':\n case 'retired':\n // A record was never asking for an obligation, so it cannot take one on.\n // Sealed for the same reason a retired document is, by a different route.\n case 'record':\n return 'sealed';\n case 'unknown':\n return 'unknown';\n }\n}\n\n/** True when the phase means the document no longer binds anybody. */\nexport function isRetired(phase: Phase): boolean {\n return phase === 'retired';\n}\n\n/** True when the heading names a section that declares the document status. */\nexport function isStatusHeading(text: string): boolean {\n return STATUS_HEADINGS.has(text.toLowerCase().replace(/[::*_`]/g, '').trim());\n}\n\n/**\n * Infers retirement from where a file lives.\n *\n * Moving an ADR into `docs/adr/archive/` is the most common way a team retires a\n * decision without touching its front matter, and a graph that misses it will\n * happily let obligations be delegated into the archive.\n */\nexport function phaseFromPath(posixPath: string): Phase {\n const segments = posixPath.toLowerCase().split('/');\n // The file name itself is not a directory; a document called `archive`, with\n // or without an extension, is not retired.\n for (let i = 0; i < segments.length - 1; i += 1) {\n if (RETIRING_DIRECTORIES.has(segments[i] as string)) return 'retired';\n }\n return 'unknown';\n}\n\n/**\n * Pulls a supersession target out of a status string.\n *\n * `Superseded by ADR-0009` is the single most common way a supersession is ever\n * recorded, and it is written in the status field rather than as a link. Reading\n * it here means the relation reaches the graph even in repositories that never\n * adopted an explicit field for it.\n *\n * In Chinese the successor stands between `被` and the verb: `已被 ADR-0003\n * 取代`. Left unread, a document that said so and one that said it supersedes\n * this one disagreed, and `unreciprocated-supersession` asked for a\n * `superseded-by` key the status had already given.\n */\nexport function supersessionTargetsIn(raw: string): string[] {\n const out: string[] = [];\n const plain = raw.replace(/[`*_]/g, '');\n // The tail runs to the end of the line, not of the string: a status that\n // wraps onto a second line still names its successor on the first.\n const pattern = /(?:super[sc]eded|replaced|obsoleted)\\s*(?:by|with|through)?\\s*[:\\-–—]?\\s*(.+)/i;\n const match = pattern.exec(plain);\n // A link inside the passive is read by its label, as a status is: its path\n // would run the name past the thirty characters the form allows.\n const tails = match ? [match[1] as string] : passiveSupersessions(plain.replace(/\\[([^\\]]*)\\]\\([^)]*\\)/g, '$1'));\n for (const tail of tails) {\n for (const token of tail.split(/[,;、,;和及與与]|\\band\\b/)) {\n const cleaned = token\n .replace(/\\[([^\\]]*)\\]\\([^)]*\\)/g, '$1')\n .replace(/[()<>\"'()]/g, ' ')\n .trim();\n if (cleaned.length > 0 && cleaned.length < 200) out.push(cleaned);\n }\n }\n return out;\n}\n"]}
package/dist/report.d.ts CHANGED
@@ -47,6 +47,14 @@ export interface ReporterOptions {
47
47
  readonly ratchet?: boolean | undefined;
48
48
  readonly entries?: readonly StaleEntry[] | undefined;
49
49
  } | undefined;
50
+ /**
51
+ * The baseline's path as it is typed where the run was started, for the
52
+ * command that re-records it; `baseline.source` when unset. The source reads
53
+ * from the root, and typed in a directory below it, it names another file.
54
+ * Beside the baseline rather than in it, because the JSON report writes the
55
+ * baseline whole.
56
+ */
57
+ readonly recordAs?: string | undefined;
50
58
  }
51
59
  export interface ColorEnvironment {
52
60
  readonly isTTY?: boolean | undefined;
@@ -142,6 +150,20 @@ export declare function formatSarif(result: AnalysisResult, graph: SpecGraph, op
142
150
  export declare function formatGitlab(result: AnalysisResult, options?: {
143
151
  escalated?: ReadonlySet<AnyRuleId> | undefined;
144
152
  }): string;
153
+ /**
154
+ * Findings as GitHub Actions workflow commands, one line each, which a job's
155
+ * log turns into annotations on the pull request's diff with no upload step -
156
+ * what SARIF does through code scanning, for a repository that has none.
157
+ *
158
+ * An error is `error`, a warning `warning` and a note `notice`, as spec-guard
159
+ * writes them. The rule is the title, marked `(strict)` where only `--strict`
160
+ * made it an error, as the Markdown report marks it; the message carries the
161
+ * hint, since a finding without its next action is the half a reader cannot
162
+ * act on. Nothing at all for a clean run.
163
+ */
164
+ export declare function formatGithub(result: AnalysisResult, options?: {
165
+ escalated?: ReadonlySet<AnyRuleId> | undefined;
166
+ }): string;
145
167
  export declare function formatJson(result: AnalysisResult, options?: {
146
168
  escalated?: ReadonlySet<AnyRuleId>;
147
169
  baseline?: ReporterOptions['baseline'];
package/dist/report.js CHANGED
@@ -14,6 +14,7 @@ import { createHash } from 'node:crypto';
14
14
  import { fingerprintOf } from './baseline.js';
15
15
  import { formatRef } from './source.js';
16
16
  import { DEFAULT_SEVERITIES, RULE_DESCRIPTIONS, RULE_IDS } from './rules.js';
17
+ import { displayWidth } from './vendor/spec-core/text/index.js';
17
18
  /** Honours `NO_COLOR`, `FORCE_COLOR` and `TERM=dumb` before falling back to TTY. */
18
19
  export function shouldUseColor(environment = {}) {
19
20
  const env = environment.env ?? process.env;
@@ -139,7 +140,7 @@ export function formatReport(result, options = {}) {
139
140
  // anything that is not one counts the same zero.
140
141
  const gone = (baseline.entries ?? []).filter((entry) => entry.reason === 'gone').length;
141
142
  const because = gone === 0
142
- ? `tighten it: spec-graph check --record-baseline ${baseline.source}`
143
+ ? `tighten it: spec-graph check --record-baseline ${options.recordAs ?? baseline.source}`
143
144
  : `${gone} of them ${gone === 1 ? 'names a document' : 'name documents'} this run did not see - check the include patterns before re-recording`;
144
145
  const text = `${baseline.stale} baseline ${plural(baseline.stale, 'entry', 'entries')} no longer ${baseline.stale === 1 ? 'occurs' : 'occur'} - ${because}`;
145
146
  lines.push(ratcheted ? `${paint.error(marks.error)} ${text}` : paint.dim(text));
@@ -164,10 +165,14 @@ function formatDiagnostic(diagnostic, paint, marks, escalated) {
164
165
  `${mark} ${paint.location(formatRef(diagnostic.at))} ${paint.dim(label)}`,
165
166
  ` ${diagnostic.message}`,
166
167
  ];
167
- const width = Math.max(0, ...diagnostic.related.map((entry) => formatRef(entry.at).length));
168
+ // Padded to the columns a terminal draws, not to UTF-16 units: `docs/決策.md`
169
+ // is ten units and twelve columns, so `.padEnd` left the notes beside it two
170
+ // columns out of line.
171
+ const width = Math.max(0, ...diagnostic.related.map((entry) => displayWidth(formatRef(entry.at))));
168
172
  for (const entry of diagnostic.related) {
169
173
  const reference = formatRef(entry.at);
170
- lines.push(` ${paint.dim(marks.related)} ${paint.dim(reference.padEnd(width))} ${paint.dim(entry.note)}`);
174
+ const padded = `${reference}${' '.repeat(width - displayWidth(reference))}`;
175
+ lines.push(` ${paint.dim(marks.related)} ${paint.dim(padded)} ${paint.dim(entry.note)}`);
171
176
  }
172
177
  lines.push(` ${paint.hint(marks.hint)} ${paint.hint(diagnostic.hint)}`);
173
178
  return lines;
@@ -525,6 +530,48 @@ function gitlabSeverity(severity, escalated) {
525
530
  return 'info';
526
531
  }
527
532
  }
533
+ /* -------------------------------------------------------------------------- */
534
+ /* GitHub workflow commands */
535
+ /* -------------------------------------------------------------------------- */
536
+ /** A workflow command's message: `%`, and the line breaks that would end the command. */
537
+ function escapeData(text) {
538
+ return text.replace(/%/g, '%25').replace(/\r/g, '%0D').replace(/\n/g, '%0A');
539
+ }
540
+ /** A workflow command's property: as a message, and the `:` and `,` that would end the property. */
541
+ function escapeProperty(text) {
542
+ return escapeData(text).replace(/:/g, '%3A').replace(/,/g, '%2C');
543
+ }
544
+ /**
545
+ * Findings as GitHub Actions workflow commands, one line each, which a job's
546
+ * log turns into annotations on the pull request's diff with no upload step -
547
+ * what SARIF does through code scanning, for a repository that has none.
548
+ *
549
+ * An error is `error`, a warning `warning` and a note `notice`, as spec-guard
550
+ * writes them. The rule is the title, marked `(strict)` where only `--strict`
551
+ * made it an error, as the Markdown report marks it; the message carries the
552
+ * hint, since a finding without its next action is the half a reader cannot
553
+ * act on. Nothing at all for a clean run.
554
+ */
555
+ export function formatGithub(result, options = {}) {
556
+ const escalated = options.escalated ?? EMPTY_RULES;
557
+ return result.diagnostics
558
+ .map((diagnostic) => {
559
+ const title = escalated.has(diagnostic.rule) ? `${diagnostic.rule} (strict)` : diagnostic.rule;
560
+ const place = `file=${escapeProperty(diagnostic.at.file)},line=${diagnostic.at.span.start.line}`;
561
+ return `::${githubLevel(diagnostic.severity)} ${place},title=${escapeProperty(title)}::${escapeData(`${diagnostic.message}. ${diagnostic.hint}`)}\n`;
562
+ })
563
+ .join('');
564
+ }
565
+ function githubLevel(severity) {
566
+ switch (severity) {
567
+ case 'error':
568
+ return 'error';
569
+ case 'warn':
570
+ return 'warning';
571
+ default:
572
+ return 'notice';
573
+ }
574
+ }
528
575
  export function formatJson(result, options = {}) {
529
576
  const escalated = options.escalated ?? EMPTY_RULES;
530
577
  return `${JSON.stringify({