yarramate 0.8.0 → 0.9.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.
@@ -1,6 +1,6 @@
1
1
  format: yarramate/question-catalogue/v1
2
2
  id: core-enrichment
3
- version: "0.4"
3
+ version: "0.5"
4
4
  profile: yarramate/core@0.1
5
5
  presentation:
6
6
  title: Core enrichment interview
@@ -122,7 +122,9 @@ questions:
122
122
  authority: either
123
123
  resolution: >-
124
124
  Add realization relationships from the fulfilling capability or
125
- service, or record the aspirational status in the goal's description.
125
+ service, record the aspirational status in the goal's description,
126
+ or retire the goal (status: retired) to record that it no longer
127
+ steers design.
126
128
 
127
129
 
128
130
  - id: goal-no-driver
@@ -219,8 +221,11 @@ questions:
219
221
  roadmap.
220
222
  authority: either
221
223
  resolution: >-
222
- Add realization from the fulfilling service, component, or behavior,
223
- or record the descoping decision and remove the requirement.
224
+ Add realization from the fulfilling service, component, or
225
+ behavior; to descope instead, retire the requirement (status:
226
+ retired) — retirement preserves the decision on record and closes
227
+ the question (ADR 0064). Delete only when the history itself is
228
+ noise.
224
229
 
225
230
  - id: principle-unapplied
226
231
  wave: motivation
@@ -123,7 +123,24 @@ const itemMap = (source, collection, id) => {
123
123
  };
124
124
  // The indent item fields sit at, read off the item's own first field.
125
125
  const fieldIndentOf = (source, map) => indentAt(source, nodeRange(map.items[0].key)[0]);
126
+ // A flow-style item (`- { id: x, ... }`) cannot take block field lines;
127
+ // splicing them after it corrupts the sequence (Codex dogfood finding,
128
+ // 2026-08-01). The whole item is rewritten as a block mapping with the
129
+ // mutation applied — churn confined to the item being edited.
130
+ const rewriteFlowItem = (source, map, mutate) => {
131
+ const mutated = mutate(map.toJSON());
132
+ const [start, valueEnd] = nodeRange(map);
133
+ const fieldIndent = indentAt(source, start);
134
+ const rendered = reindent(stringify(mutated, { lineWidth: 0 }).trimEnd(), fieldIndent);
135
+ return splice(source, start, valueEnd, rendered);
136
+ };
126
137
  const setScalarField = (source, map, key, value) => {
138
+ if (map.flow) {
139
+ return rewriteFlowItem(source, map, (fields) => ({
140
+ ...fields,
141
+ [key]: value,
142
+ }));
143
+ }
127
144
  const indent = fieldIndentOf(source, map);
128
145
  const rendered = reindent(valueText(value), indent + 2);
129
146
  const pair = pairFor(map, key);
@@ -134,6 +151,15 @@ const setScalarField = (source, map, key, value) => {
134
151
  return splice(source, start, valueEnd, rendered);
135
152
  };
136
153
  const appendListField = (source, map, key, additions) => {
154
+ if (map.flow) {
155
+ return rewriteFlowItem(source, map, (fields) => ({
156
+ ...fields,
157
+ [key]: [
158
+ ...(fields[key] ?? []),
159
+ ...additions,
160
+ ],
161
+ }));
162
+ }
137
163
  const indent = fieldIndentOf(source, map);
138
164
  const pair = pairFor(map, key);
139
165
  if (pair === undefined) {
@@ -163,11 +189,19 @@ const appendListField = (source, map, key, additions) => {
163
189
  return splice(source, start, valueEnd, `\n${sequenceEntries(merged, indent + 2)}`);
164
190
  };
165
191
  // Retraction (#115): delete the field's whole entry, from the start of its
166
- // key line through the end of its value's last line.
192
+ // key line through the end of its value's last line. A flow item is
193
+ // rewritten instead — line-based deletion there would take the whole item
194
+ // with it.
167
195
  const removeField = (source, map, key) => {
168
196
  const pair = pairFor(map, key);
169
197
  if (pair === undefined)
170
198
  return undefined;
199
+ if (map.flow) {
200
+ return rewriteFlowItem(source, map, (fields) => {
201
+ const { [key]: _removed, ...rest } = fields;
202
+ return rest;
203
+ });
204
+ }
171
205
  const start = lineStartOf(source, nodeRange(pair.key)[0]);
172
206
  const valueEnd = pair.value === null || pair.value === undefined
173
207
  ? nodeRange(pair.key)[2]
@@ -4,6 +4,7 @@ import { fileURLToPath } from 'node:url';
4
4
  import { parseDocument } from 'yaml';
5
5
  import { compareArchitectureStates, } from './architecture-state.js';
6
6
  import { renderBrief } from './brief.js';
7
+ import { deriveChangedSubjects } from './changed.js';
7
8
  import { runCheckCommand } from './check-command.js';
8
9
  import { diagnosticJson, humanDiagnostics, usage, } from './cli-support.js';
9
10
  import { compileWorkspaceWithProfileContext, } from './compiler.js';
@@ -124,6 +125,7 @@ export function runAskCommand(options, cwd) {
124
125
  let kinds = false;
125
126
  let advise = false;
126
127
  let compare;
128
+ let changed;
127
129
  let budget;
128
130
  let kindFilter;
129
131
  let statusFilter;
@@ -172,7 +174,8 @@ export function runAskCommand(options, cwd) {
172
174
  if (option === '--budget' ||
173
175
  option === '--kind' ||
174
176
  option === '--status' ||
175
- option === '--catalogue') {
177
+ option === '--catalogue' ||
178
+ option === '--changed') {
176
179
  const value = options[index + 1];
177
180
  if (value === undefined || value.startsWith('-')) {
178
181
  return { exitCode: 2, stdout: '', stderr: usage };
@@ -183,6 +186,12 @@ export function runAskCommand(options, cwd) {
183
186
  }
184
187
  budget = Number(value);
185
188
  }
189
+ else if (option === '--changed') {
190
+ if (changed !== undefined) {
191
+ return { exitCode: 2, stdout: '', stderr: usage };
192
+ }
193
+ changed = value;
194
+ }
186
195
  else if (option === '--kind') {
187
196
  if (kindFilter !== undefined) {
188
197
  return { exitCode: 2, stdout: '', stderr: usage };
@@ -223,9 +232,12 @@ export function runAskCommand(options, cwd) {
223
232
  (advise && exclusiveModes > 0) ||
224
233
  (advise && query.length === 0) ||
225
234
  (query.length > 0 && exclusiveModes > 0) ||
235
+ (changed !== undefined &&
236
+ (query.length > 0 || exclusiveModes > 0 || advise)) ||
226
237
  ((kindFilter !== undefined || statusFilter !== undefined) && !subjects) ||
227
238
  (cataloguePath !== undefined && !open && !advise) ||
228
- (budget !== undefined && (json || (query.length === 0 && !advise)))) {
239
+ (budget !== undefined &&
240
+ (json || (query.length === 0 && !advise && changed === undefined)))) {
229
241
  return { exitCode: 2, stdout: '', stderr: usage };
230
242
  }
231
243
  try {
@@ -263,7 +275,8 @@ export function runAskCommand(options, cwd) {
263
275
  !open &&
264
276
  !kinds &&
265
277
  !advise &&
266
- compare === undefined) {
278
+ compare === undefined &&
279
+ changed === undefined) {
267
280
  const checked = runCheckCommand([workspacePath, '--json'], cwd);
268
281
  const checkPayload = JSON.parse(checked.stdout);
269
282
  if (!checkPayload.ok) {
@@ -544,6 +557,83 @@ export function runAskCommand(options, cwd) {
544
557
  };
545
558
  return emit(result, renderInterrogationReport(ordered));
546
559
  }
560
+ // --changed: the review slice (ADR 0065). Git says what changed; the
561
+ // engine maps changed lines to subjects and renders their connected
562
+ // neighbourhood, plus a coverage note when a changed subject appears
563
+ // in no authored projection.
564
+ if (changed !== undefined) {
565
+ const documentIdByPath = new Map(graph.documents.map(({ id, source }) => [source, id]));
566
+ const derived = deriveChangedSubjects(cwd, changed, workspace.documents.map((path) => ({
567
+ path,
568
+ source: readFileSync(resolve(cwd, path), 'utf8'),
569
+ documentId: documentIdByPath.get(path) ?? path,
570
+ })));
571
+ if (!derived.ok) {
572
+ return { exitCode: 2, stdout: '', stderr: `${derived.message}\n` };
573
+ }
574
+ const endpoints = new Set();
575
+ for (const relationshipId of derived.changed.relationships) {
576
+ const claim = graph.claims.find((candidate) => candidate.id === relationshipId && 'ref' in candidate.object);
577
+ if (claim !== undefined && 'ref' in claim.object) {
578
+ endpoints.add(claim.subject);
579
+ endpoints.add(claim.object.ref);
580
+ }
581
+ }
582
+ const seeds = [
583
+ ...new Set([...derived.changed.concepts, ...endpoints]),
584
+ ].sort();
585
+ const changedIds = [
586
+ ...derived.changed.concepts,
587
+ ...derived.changed.relationships,
588
+ ];
589
+ const covered = new Set();
590
+ for (const projectionPath of workspace.projections) {
591
+ const loaded = loadProjection({
592
+ path: projectionPath,
593
+ source: readFileSync(resolve(cwd, projectionPath), 'utf8'),
594
+ });
595
+ if (!loaded.ok)
596
+ continue;
597
+ const membership = evaluateProjection(graph, loaded.projection, compilation.profileContext);
598
+ for (const subject of membership.subjects) {
599
+ covered.add(subject.id);
600
+ }
601
+ }
602
+ const uncovered = changedIds.filter((id) => !covered.has(id));
603
+ const coverage = {
604
+ projections: workspace.projections.length,
605
+ uncovered,
606
+ };
607
+ const evaluated = sliceProjection(graph, seeds, `Review slice ${changed}`, compilation.profileContext);
608
+ const result = {
609
+ format: 'yarramate/ask-result/v1',
610
+ workspace: workspace.id,
611
+ mode: 'slice',
612
+ addressing: 'changed',
613
+ seeds,
614
+ changed: derived.changed,
615
+ coverage,
616
+ result: evaluated,
617
+ };
618
+ if (changedIds.length === 0) {
619
+ return emit(result, `No model subjects changed in ${changed}.\n`);
620
+ }
621
+ const rendered = budget === undefined
622
+ ? renderBrief(evaluated, compilation.profileContext)
623
+ : renderBudgetedContext(evaluated, budget);
624
+ const lines = [
625
+ `Review slice ${changed} — ${plural(derived.changed.concepts.length, 'concept')}, ` +
626
+ `${plural(derived.changed.relationships.length, 'relationship')} changed (workspace ${workspace.id})`,
627
+ '',
628
+ rendered.trimEnd(),
629
+ '',
630
+ uncovered.length === 0
631
+ ? `Review coverage: every changed subject appears in at least one of the ${coverage.projections} authored projections.`
632
+ : `Review coverage: ${uncovered.length} of ${changedIds.length} changed subjects appear in no authored projection:` +
633
+ `\n${uncovered.map((id) => ` ${id}`).join('\n')}`,
634
+ ];
635
+ return emit(result, `${lines.join('\n')}\n`);
636
+ }
547
637
  // Slice and advice both start from seeds. A single query term that
548
638
  // names a projection file is precise addressing; anything else runs
549
639
  // through free-text seeding, where exact subject ids win.
@@ -0,0 +1,17 @@
1
+ export interface ChangedSubjects {
2
+ readonly range: string;
3
+ readonly concepts: readonly string[];
4
+ readonly relationships: readonly string[];
5
+ }
6
+ export type ChangedResult = {
7
+ readonly ok: true;
8
+ readonly changed: ChangedSubjects;
9
+ } | {
10
+ readonly ok: false;
11
+ readonly message: string;
12
+ };
13
+ export declare function deriveChangedSubjects(cwd: string, range: string, documents: ReadonlyArray<{
14
+ readonly path: string;
15
+ readonly source: string;
16
+ readonly documentId: string;
17
+ }>): ChangedResult;
@@ -0,0 +1,111 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { isMap, isScalar, isSeq, parseDocument } from 'yaml';
3
+ const lineOfOffset = (lineStarts, offset) => {
4
+ let low = 0;
5
+ let high = lineStarts.length - 1;
6
+ while (low < high) {
7
+ const mid = (low + high + 1) >> 1;
8
+ if (lineStarts[mid] <= offset)
9
+ low = mid;
10
+ else
11
+ high = mid - 1;
12
+ }
13
+ return low + 1;
14
+ };
15
+ const itemSpans = (source) => {
16
+ const lineStarts = [0];
17
+ for (let index = 0; index < source.length; index += 1) {
18
+ if (source[index] === '\n')
19
+ lineStarts.push(index + 1);
20
+ }
21
+ const document = parseDocument(source);
22
+ const root = document.contents;
23
+ if (!isMap(root))
24
+ return [];
25
+ const spans = [];
26
+ for (const collection of ['concepts', 'relationships', 'states']) {
27
+ const pair = root.items.find((candidate) => isScalar(candidate.key) && candidate.key.value === collection);
28
+ if (pair === undefined || !isSeq(pair.value))
29
+ continue;
30
+ for (const item of pair.value.items) {
31
+ if (!isMap(item))
32
+ continue;
33
+ const idPair = item.items.find((field) => isScalar(field.key) && field.key.value === 'id');
34
+ if (idPair === undefined ||
35
+ !isScalar(idPair.value) ||
36
+ typeof idPair.value.value !== 'string') {
37
+ continue;
38
+ }
39
+ const range = item
40
+ .range;
41
+ if (range === undefined)
42
+ continue;
43
+ spans.push({
44
+ id: idPair.value.value,
45
+ collection,
46
+ startLine: lineOfOffset(lineStarts, range[0]),
47
+ // range[1] can extend past the trailing newline; anchor the end
48
+ // on the last content character instead.
49
+ endLine: lineOfOffset(lineStarts, Math.max(range[0], range[1] - 1)),
50
+ });
51
+ }
52
+ }
53
+ return spans;
54
+ };
55
+ // New-side line ranges from `git diff --unified=0`. A pure deletion has a
56
+ // zero count; it still touches the position it collapsed onto.
57
+ const changedLineRanges = (diff) => {
58
+ const ranges = [];
59
+ for (const match of diff.matchAll(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/gm)) {
60
+ const start = Number(match[1]);
61
+ const count = match[2] === undefined ? 1 : Number(match[2]);
62
+ ranges.push(count === 0 ? [Math.max(start, 1), Math.max(start, 1)] : [start, start + count - 1]);
63
+ }
64
+ return ranges;
65
+ };
66
+ export function deriveChangedSubjects(cwd, range, documents) {
67
+ const probe = spawnSync('git', ['-C', cwd, 'rev-parse', '--git-dir'], {
68
+ encoding: 'utf8',
69
+ });
70
+ if (probe.status !== 0) {
71
+ return {
72
+ ok: false,
73
+ message: '--changed requires the workspace to live in a git repository',
74
+ };
75
+ }
76
+ const concepts = new Set();
77
+ const relationships = new Set();
78
+ for (const document of documents) {
79
+ const diffed = spawnSync('git', ['-C', cwd, 'diff', '--unified=0', range, '--', document.path], { encoding: 'utf8' });
80
+ if (diffed.status !== 0) {
81
+ return {
82
+ ok: false,
83
+ message: `git diff failed for range "${range}": ${(diffed.stderr ?? '').trim()}`,
84
+ };
85
+ }
86
+ const ranges = changedLineRanges(diffed.stdout ?? '');
87
+ if (ranges.length === 0)
88
+ continue;
89
+ const spans = itemSpans(document.source);
90
+ for (const span of spans) {
91
+ if (span.collection === 'states')
92
+ continue;
93
+ const touched = ranges.some(([from, to]) => from <= span.endLine && to >= span.startLine);
94
+ if (!touched)
95
+ continue;
96
+ const qualified = `${document.documentId}#${span.id}`;
97
+ if (span.collection === 'concepts')
98
+ concepts.add(qualified);
99
+ else
100
+ relationships.add(qualified);
101
+ }
102
+ }
103
+ return {
104
+ ok: true,
105
+ changed: {
106
+ range,
107
+ concepts: [...concepts].sort(),
108
+ relationships: [...relationships].sort(),
109
+ },
110
+ };
111
+ }
@@ -7,7 +7,7 @@ export interface CliResult {
7
7
  export declare const isMainModule: (moduleUrl: string, entrypoint: string | undefined) => boolean;
8
8
  export declare const packageVersion: string;
9
9
  export declare const versionResult: (binary: string) => CliResult;
10
- export declare const usage = "Usage:\n yarramate init <directory> [--no-pointer]\n yarramate design <workspace.yaml> [--subject <document-id>#<local-id>] [--catalogue <catalogue.yaml>] [--json]\n yarramate apply <operations.yaml> <workspace.yaml> [--json]\n yarramate ask <workspace.yaml> [--json]\n yarramate ask <workspace.yaml> \"<free text>\" | <document-id>#<local-id> ... | <projection.yaml> [--budget <tokens>] [--json]\n yarramate ask <workspace.yaml> --subjects [--kind <term>] [--status <status>] [--json]\n yarramate ask <workspace.yaml> --kinds [--json]\n yarramate ask <workspace.yaml> --advise \"<topic>\" [--budget <tokens>] [--catalogue <catalogue.yaml>] [--json]\n yarramate ask <workspace.yaml> --next [--json]\n yarramate ask <workspace.yaml> --open [--catalogue <catalogue.yaml>] [--json]\n yarramate ask <workspace.yaml> --compare <from-state> <to-state> [--json]\n yarramate check <source.yaml> [source.yaml ...] [--json] [--strict]\n yarramate reconcile <workspace.yaml>\n yarramate export graph <workspace.yaml> [--out <file>]\n yarramate export markdown <projection.yaml> <workspace.yaml> [--out <file>]\n yarramate export briefs <projection.yaml> <workspace.yaml> --out <directory> [--budget <tokens>]\n yarramate export likec4 <likec4-project.yaml> <output-dir> <workspace.yaml>\n";
10
+ export declare const usage = "Usage:\n yarramate init <directory> [--no-pointer]\n yarramate design <workspace.yaml> [--subject <document-id>#<local-id>] [--catalogue <catalogue.yaml>] [--json]\n yarramate apply <operations.yaml> <workspace.yaml> [--json]\n yarramate ask <workspace.yaml> [--json]\n yarramate ask <workspace.yaml> \"<free text>\" | <document-id>#<local-id> ... | <projection.yaml> [--budget <tokens>] [--json]\n yarramate ask <workspace.yaml> --subjects [--kind <term>] [--status <status>] [--json]\n yarramate ask <workspace.yaml> --kinds [--json]\n yarramate ask <workspace.yaml> --advise \"<topic>\" [--budget <tokens>] [--catalogue <catalogue.yaml>] [--json]\n yarramate ask <workspace.yaml> --next [--json]\n yarramate ask <workspace.yaml> --open [--catalogue <catalogue.yaml>] [--json]\n yarramate ask <workspace.yaml> --compare <from-state> <to-state> [--json]\n yarramate ask <workspace.yaml> --changed <git-range> [--budget <tokens>] [--json]\n yarramate check <source.yaml> [source.yaml ...] [--json] [--strict]\n yarramate reconcile <workspace.yaml>\n yarramate export graph <workspace.yaml> [--out <file>]\n yarramate export markdown <projection.yaml> <workspace.yaml> [--out <file>]\n yarramate export markdown --changed <git-range> <workspace.yaml> [--out <file>]\n yarramate export briefs <projection.yaml> <workspace.yaml> --out <directory> [--budget <tokens>]\n yarramate export briefs --changed <git-range> <workspace.yaml> --out <directory> [--budget <tokens>]\n yarramate export likec4 <likec4-project.yaml> <output-dir> <workspace.yaml>\n";
11
11
  export declare const diagnosticJson: (diagnostics: unknown) => string;
12
12
  export declare const checkResultJson: (ok: boolean, diagnostics: unknown, counted?: {
13
13
  readonly documents: number;
@@ -23,7 +23,7 @@ export const versionResult = (binary) => ({
23
23
  stdout: `${binary} ${packageVersion}\n`,
24
24
  stderr: '',
25
25
  });
26
- export const usage = 'Usage:\n yarramate init <directory> [--no-pointer]\n yarramate design <workspace.yaml> [--subject <document-id>#<local-id>] [--catalogue <catalogue.yaml>] [--json]\n yarramate apply <operations.yaml> <workspace.yaml> [--json]\n yarramate ask <workspace.yaml> [--json]\n yarramate ask <workspace.yaml> "<free text>" | <document-id>#<local-id> ... | <projection.yaml> [--budget <tokens>] [--json]\n yarramate ask <workspace.yaml> --subjects [--kind <term>] [--status <status>] [--json]\n yarramate ask <workspace.yaml> --kinds [--json]\n yarramate ask <workspace.yaml> --advise "<topic>" [--budget <tokens>] [--catalogue <catalogue.yaml>] [--json]\n yarramate ask <workspace.yaml> --next [--json]\n yarramate ask <workspace.yaml> --open [--catalogue <catalogue.yaml>] [--json]\n yarramate ask <workspace.yaml> --compare <from-state> <to-state> [--json]\n yarramate check <source.yaml> [source.yaml ...] [--json] [--strict]\n yarramate reconcile <workspace.yaml>\n yarramate export graph <workspace.yaml> [--out <file>]\n yarramate export markdown <projection.yaml> <workspace.yaml> [--out <file>]\n yarramate export briefs <projection.yaml> <workspace.yaml> --out <directory> [--budget <tokens>]\n yarramate export likec4 <likec4-project.yaml> <output-dir> <workspace.yaml>\n';
26
+ export const usage = 'Usage:\n yarramate init <directory> [--no-pointer]\n yarramate design <workspace.yaml> [--subject <document-id>#<local-id>] [--catalogue <catalogue.yaml>] [--json]\n yarramate apply <operations.yaml> <workspace.yaml> [--json]\n yarramate ask <workspace.yaml> [--json]\n yarramate ask <workspace.yaml> "<free text>" | <document-id>#<local-id> ... | <projection.yaml> [--budget <tokens>] [--json]\n yarramate ask <workspace.yaml> --subjects [--kind <term>] [--status <status>] [--json]\n yarramate ask <workspace.yaml> --kinds [--json]\n yarramate ask <workspace.yaml> --advise "<topic>" [--budget <tokens>] [--catalogue <catalogue.yaml>] [--json]\n yarramate ask <workspace.yaml> --next [--json]\n yarramate ask <workspace.yaml> --open [--catalogue <catalogue.yaml>] [--json]\n yarramate ask <workspace.yaml> --compare <from-state> <to-state> [--json]\n yarramate ask <workspace.yaml> --changed <git-range> [--budget <tokens>] [--json]\n yarramate check <source.yaml> [source.yaml ...] [--json] [--strict]\n yarramate reconcile <workspace.yaml>\n yarramate export graph <workspace.yaml> [--out <file>]\n yarramate export markdown <projection.yaml> <workspace.yaml> [--out <file>]\n yarramate export markdown --changed <git-range> <workspace.yaml> [--out <file>]\n yarramate export briefs <projection.yaml> <workspace.yaml> --out <directory> [--budget <tokens>]\n yarramate export briefs --changed <git-range> <workspace.yaml> --out <directory> [--budget <tokens>]\n yarramate export likec4 <likec4-project.yaml> <output-dir> <workspace.yaml>\n';
27
27
  export const diagnosticJson = (diagnostics) => `${JSON.stringify({
28
28
  format: 'yarramate/diagnostic-result/v1',
29
29
  diagnostics,
@@ -4,6 +4,7 @@ import { dirname, join, resolve } from 'node:path';
4
4
  import { fileURLToPath } from 'node:url';
5
5
  import { parseDocument } from 'yaml';
6
6
  import { renderBrief } from './brief.js';
7
+ import { deriveChangedSubjects } from './changed.js';
7
8
  import { humanDiagnostics, usage } from './cli-support.js';
8
9
  import { compileWorkspaceWithProfileContext, } from './compiler.js';
9
10
  import { serializeSemanticGraph } from './graph.js';
@@ -23,6 +24,7 @@ const parseExportOptions = (options) => {
23
24
  const positionals = [];
24
25
  let out;
25
26
  let budget;
27
+ let changed;
26
28
  let json = false;
27
29
  for (let index = 0; index < options.length; index += 1) {
28
30
  const option = options[index];
@@ -30,7 +32,9 @@ const parseExportOptions = (options) => {
30
32
  json = true;
31
33
  continue;
32
34
  }
33
- if (option === '--out' || option === '--budget') {
35
+ if (option === '--out' ||
36
+ option === '--budget' ||
37
+ option === '--changed') {
34
38
  const value = options[index + 1];
35
39
  if (value === undefined || value.startsWith('-'))
36
40
  return undefined;
@@ -39,6 +43,11 @@ const parseExportOptions = (options) => {
39
43
  return undefined;
40
44
  out = value;
41
45
  }
46
+ else if (option === '--changed') {
47
+ if (changed !== undefined)
48
+ return undefined;
49
+ changed = value;
50
+ }
42
51
  else {
43
52
  if (budget !== undefined || !/^[1-9][0-9]*$/.test(value)) {
44
53
  return undefined;
@@ -56,6 +65,7 @@ const parseExportOptions = (options) => {
56
65
  positionals,
57
66
  ...(out === undefined ? {} : { out }),
58
67
  ...(budget === undefined ? {} : { budget }),
68
+ ...(changed === undefined ? {} : { changed }),
59
69
  json,
60
70
  };
61
71
  };
@@ -103,12 +113,14 @@ export function runExportCommand(options, cwd) {
103
113
  stderr: delegated.stderr ?? '',
104
114
  };
105
115
  }
106
- const expectedPositionals = kind === 'graph' ? 1 : 2;
116
+ const usesChanged = parsed.changed !== undefined;
117
+ const expectedPositionals = kind === 'graph' || usesChanged ? 1 : 2;
107
118
  const workspacePath = parsed.positionals[expectedPositionals - 1];
108
- const projectionPath = kind === 'graph' ? undefined : parsed.positionals[0];
119
+ const projectionPath = kind === 'graph' || usesChanged ? undefined : parsed.positionals[0];
109
120
  if (parsed.positionals.length !== expectedPositionals ||
110
121
  workspacePath === undefined ||
111
122
  parsed.json ||
123
+ (usesChanged && kind === 'graph') ||
112
124
  (parsed.budget !== undefined && kind !== 'briefs') ||
113
125
  (kind === 'briefs' && parsed.out === undefined)) {
114
126
  return { exitCode: 2, stdout: '', stderr: usage };
@@ -151,13 +163,51 @@ export function runExportCommand(options, cwd) {
151
163
  stderr: '',
152
164
  };
153
165
  }
154
- const loadedProjection = loadProjection({
155
- path: projectionPath,
156
- source: readFileSync(resolve(cwd, projectionPath), 'utf8'),
157
- });
158
- if (!loadedProjection.ok)
159
- return failed(loadedProjection.diagnostics);
160
- const result = evaluateProjection(compilation.graph, loadedProjection.projection, compilation.profileContext);
166
+ let result;
167
+ if (parsed.changed !== undefined) {
168
+ // Review slices derive from git (ADR 0065): changed subjects seed
169
+ // the connected neighbourhood the reviewer inspects.
170
+ const documentIdByPath = new Map(compilation.graph.documents.map(({ id, source }) => [source, id]));
171
+ const derived = deriveChangedSubjects(cwd, parsed.changed, workspace.documents.map((path) => ({
172
+ path,
173
+ source: readFileSync(resolve(cwd, path), 'utf8'),
174
+ documentId: documentIdByPath.get(path) ?? path,
175
+ })));
176
+ if (!derived.ok) {
177
+ return { exitCode: 2, stdout: '', stderr: `${derived.message}\n` };
178
+ }
179
+ const endpoints = new Set();
180
+ for (const relationshipId of derived.changed.relationships) {
181
+ const claim = compilation.graph.claims.find((candidate) => candidate.id === relationshipId && 'ref' in candidate.object);
182
+ if (claim !== undefined && 'ref' in claim.object) {
183
+ endpoints.add(claim.subject);
184
+ endpoints.add(claim.object.ref);
185
+ }
186
+ }
187
+ const seeds = [
188
+ ...new Set([...derived.changed.concepts, ...endpoints]),
189
+ ].sort();
190
+ result = evaluateProjection(compilation.graph, {
191
+ format: 'yarramate/projection/v1',
192
+ id: 'review-slice',
193
+ version: '0.0',
194
+ query: { subjects: seeds, relationships: 'connected' },
195
+ presentation: {
196
+ title: `Review slice ${parsed.changed}`,
197
+ description: `Connected neighbourhood of the subjects changed in ` +
198
+ `${parsed.changed}.`,
199
+ },
200
+ }, compilation.profileContext);
201
+ }
202
+ else {
203
+ const loadedProjection = loadProjection({
204
+ path: projectionPath,
205
+ source: readFileSync(resolve(cwd, projectionPath), 'utf8'),
206
+ });
207
+ if (!loadedProjection.ok)
208
+ return failed(loadedProjection.diagnostics);
209
+ result = evaluateProjection(compilation.graph, loadedProjection.projection, compilation.profileContext);
210
+ }
161
211
  if (kind === 'markdown') {
162
212
  const rendered = renderProjectionMarkdown(result);
163
213
  if (parsed.out === undefined) {
@@ -12,12 +12,6 @@ const indexGraph = (graph) => {
12
12
  const stateSubjects = new Set(graph.claims
13
13
  .filter(({ predicate }) => predicate === 'yarramate/state/type')
14
14
  .map(({ subject }) => subject));
15
- // Architecture states carry concept subjects in the graph but are not
16
- // enrichment targets; the catalogue interrogates the model, not the
17
- // planning overlay.
18
- const concepts = new Set(graph.subjects
19
- .filter(({ id, type }) => type === 'concept' && !stateSubjects.has(id))
20
- .map(({ id }) => id));
21
15
  const claimsBySubject = new Map();
22
16
  const kindOf = new Map();
23
17
  const nameOf = new Map();
@@ -50,6 +44,16 @@ const indexGraph = (graph) => {
50
44
  }
51
45
  }
52
46
  }
47
+ // Architecture states carry concept subjects in the graph but are not
48
+ // enrichment targets; the catalogue interrogates the model, not the
49
+ // planning overlay. Retired concepts are excluded for the same reason
50
+ // (ADR 0064): retirement is the recorded decision that a subject left
51
+ // the design conversation, so no question stays open against it.
52
+ const concepts = new Set(graph.subjects
53
+ .filter(({ id, type }) => type === 'concept' &&
54
+ !stateSubjects.has(id) &&
55
+ statusOf.get(id) !== 'retired')
56
+ .map(({ id }) => id));
53
57
  return {
54
58
  concepts,
55
59
  claimsBySubject,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "yarramate",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "Tool-neutral semantic architecture engine and guided methodology",
5
5
  "license": "MIT",
6
6
  "repository": {