@llman-sdd/core 0.3.1 → 0.5.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.
- package/package.json +2 -1
- package/src/archive/freeze.ts +86 -18
- package/src/archive/frozenCard.ts +105 -0
- package/src/archive/sevenzip.ts +15 -13
- package/src/change/closeOutHarness.ts +29 -0
- package/src/change/collect.ts +140 -0
- package/src/change/frontmatter.ts +48 -6
- package/src/change/id.ts +2 -6
- package/src/change/lifecycle.ts +285 -86
- package/src/change/nextId.ts +63 -2
- package/src/change/resolve.ts +2 -2
- package/src/change/tasks.ts +59 -0
- package/src/config/changeId.ts +14 -12
- package/src/config/load.ts +14 -0
- package/src/config/schema.ts +4 -41
- package/src/config/surface.ts +6 -36
- package/src/context/indexStore.ts +7 -3
- package/src/context/retrieve.ts +8 -10
- package/src/context/tree.ts +28 -24
- package/src/git/spawnGit.ts +90 -2
- package/src/index.ts +81 -54
- package/src/init/defaultConfig.ts +1 -5
- package/src/init/init.ts +19 -4
- package/src/ports.ts +1 -7
- package/src/project/migrateNotes.ts +104 -0
- package/src/render/machine.ts +30 -0
- package/src/report/collect.ts +11 -127
- package/src/report/graph/analysis.ts +152 -0
- package/src/report/graph/deps.ts +30 -0
- package/src/report/graph/graphData.ts +53 -0
- package/src/report/graph/nodes.ts +130 -0
- package/src/report/graph/render.ts +83 -0
- package/src/report/graph/types.ts +47 -0
- package/src/report/graph.ts +9 -381
- package/src/report/show.ts +20 -22
- package/src/report/specHelpers.ts +45 -22
- package/src/report/specs.ts +23 -25
- package/src/review/review.ts +45 -30
- package/src/spec/authoring.ts +147 -71
- package/src/spec/ir.ts +43 -15
- package/src/spec/keywords.ts +147 -0
- package/src/spec/migrateNative.ts +201 -0
- package/src/spec/parser.ts +95 -83
- package/src/spec/reqRegistry.ts +31 -15
- package/src/templates/embedded.ts +10 -16
- package/src/templates/engine.ts +10 -5
- package/src/templates/locale.ts +1 -1
- package/src/templates/skills.ts +4 -5
- package/src/validation/changeCheck.ts +128 -105
- package/src/validation/harness.ts +161 -0
- package/src/validation/staleness.ts +9 -5
- package/src/validation/validate.ts +60 -88
- package/templates/en/skills/llman-sdd-apply-cycle.md +20 -28
- package/templates/en/skills/llman-sdd-apply.md +58 -76
- package/templates/en/skills/llman-sdd-arch-review.md +12 -19
- package/templates/en/skills/llman-sdd-archive.md +27 -42
- package/templates/en/skills/llman-sdd-continue.md +17 -24
- package/templates/en/skills/llman-sdd-draft.md +17 -28
- package/templates/en/skills/llman-sdd-explore.md +29 -43
- package/templates/en/skills/llman-sdd-ff.md +12 -17
- package/templates/en/skills/llman-sdd-graph.md +14 -32
- package/templates/en/skills/llman-sdd-propose.md +48 -63
- package/templates/en/skills/llman-sdd-quick.md +12 -27
- package/templates/en/skills/llman-sdd-research.md +13 -24
- package/templates/en/skills/llman-sdd-specs-compact.md +14 -39
- package/templates/en/skills/llman-sdd-validate.md +11 -15
- package/templates/en/skills/llman-sdd-verify.md +23 -44
- package/templates/en/skills/llman-sdd-wayfinder.md +18 -22
- package/templates/en/units/skills/cli-footer.md +2 -0
- package/templates/en/units/skills/git-native-flow-brief.md +7 -6
- package/templates/en/units/skills/git-native-flow.md +21 -11
- package/templates/en/units/skills/human-readable-summary.md +2 -3
- package/templates/en/units/skills/stage-guard.md +7 -7
- package/templates/en/units/skills/structured-protocol.md +5 -8
- package/templates/en/units/skills/validation-hints.md +10 -14
- package/templates/en/units/spec/feature-contract.md +27 -16
- package/templates/en/units/workflow/archive-freeze-guidance.md +6 -3
- package/templates/zh-Hans/skills/llman-sdd-apply-cycle.md +23 -31
- package/templates/zh-Hans/skills/llman-sdd-apply.md +63 -81
- package/templates/zh-Hans/skills/llman-sdd-arch-review.md +21 -28
- package/templates/zh-Hans/skills/llman-sdd-archive.md +29 -44
- package/templates/zh-Hans/skills/llman-sdd-continue.md +17 -24
- package/templates/zh-Hans/skills/llman-sdd-draft.md +18 -29
- package/templates/zh-Hans/skills/llman-sdd-explore.md +34 -48
- package/templates/zh-Hans/skills/llman-sdd-ff.md +13 -18
- package/templates/zh-Hans/skills/llman-sdd-graph.md +16 -34
- package/templates/zh-Hans/skills/llman-sdd-propose.md +51 -65
- package/templates/zh-Hans/skills/llman-sdd-quick.md +15 -30
- package/templates/zh-Hans/skills/llman-sdd-research.md +17 -28
- package/templates/zh-Hans/skills/llman-sdd-specs-compact.md +15 -40
- package/templates/zh-Hans/skills/llman-sdd-validate.md +11 -15
- package/templates/zh-Hans/skills/llman-sdd-verify.md +26 -47
- package/templates/zh-Hans/skills/llman-sdd-wayfinder.md +25 -29
- package/templates/zh-Hans/units/skills/cli-footer.md +2 -0
- package/templates/zh-Hans/units/skills/git-native-flow-brief.md +7 -6
- package/templates/zh-Hans/units/skills/git-native-flow.md +22 -12
- package/templates/zh-Hans/units/skills/human-readable-summary.md +4 -5
- package/templates/zh-Hans/units/skills/stage-guard.md +9 -9
- package/templates/zh-Hans/units/skills/structured-protocol.md +5 -8
- package/templates/zh-Hans/units/skills/validation-hints.md +10 -14
- package/templates/zh-Hans/units/spec/feature-contract.md +25 -16
- package/templates/zh-Hans/units/workflow/archive-freeze-guidance.md +6 -2
- package/templates/en/skills/llman-sdd-onboard.md +0 -34
- package/templates/en/skills/llman-sdd-show.md +0 -24
- package/templates/en/units/migrate-prompt.md +0 -28
- package/templates/zh-Hans/skills/llman-sdd-onboard.md +0 -34
- package/templates/zh-Hans/skills/llman-sdd-show.md +0 -24
- package/templates/zh-Hans/units/migrate-prompt.md +0 -28
package/src/spec/reqRegistry.ts
CHANGED
|
@@ -1,37 +1,53 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Global rN registry (spec-parsing capability): @req:rN ids
|
|
3
|
-
* global namespace across all capability specs
|
|
4
|
-
*
|
|
2
|
+
* Global rN registry (spec-parsing capability): @req:rN ids on `规则:` blocks
|
|
3
|
+
* form a single global namespace across all capability specs. A duplicate is
|
|
4
|
+
* any id carried by more than one rule — within one file or across files
|
|
5
|
+
* (r10: 按携带该 id 的规则条数 > 1 判定).
|
|
5
6
|
*/
|
|
6
7
|
import type { CapabilityDoc } from './ir.ts';
|
|
7
8
|
|
|
9
|
+
export interface RegistryOccurrence {
|
|
10
|
+
fileName: string;
|
|
11
|
+
/** 0-based index of the rule within its doc (in-file locating). */
|
|
12
|
+
ruleIndex: number;
|
|
13
|
+
title: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
8
16
|
export interface RegistryDuplicate {
|
|
9
17
|
reqId: string;
|
|
18
|
+
/** Unique files referencing the id (sorted, display surface). */
|
|
10
19
|
files: string[];
|
|
20
|
+
/** Every rule occurrence referencing the id, in scan order. */
|
|
21
|
+
occurrences: RegistryOccurrence[];
|
|
11
22
|
}
|
|
12
23
|
|
|
13
24
|
export interface ReqRegistry {
|
|
14
|
-
/** reqId →
|
|
15
|
-
byId: Map<string,
|
|
25
|
+
/** reqId → every rule occurrence referencing it (scan order). */
|
|
26
|
+
byId: Map<string, RegistryOccurrence[]>;
|
|
16
27
|
duplicates: RegistryDuplicate[];
|
|
17
28
|
}
|
|
18
29
|
|
|
19
30
|
export function buildReqRegistry(
|
|
20
31
|
docs: readonly { fileName: string; doc: CapabilityDoc }[],
|
|
21
32
|
): ReqRegistry {
|
|
22
|
-
const byId = new Map<string,
|
|
33
|
+
const byId = new Map<string, RegistryOccurrence[]>();
|
|
23
34
|
for (const { fileName, doc } of docs) {
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
}
|
|
35
|
+
doc.rules.forEach((rule, ruleIndex) => {
|
|
36
|
+
if (rule.reqId === '') return;
|
|
37
|
+
const occurrences = byId.get(rule.reqId) ?? [];
|
|
38
|
+
occurrences.push({ fileName, ruleIndex, title: rule.title });
|
|
39
|
+
byId.set(rule.reqId, occurrences);
|
|
40
|
+
});
|
|
31
41
|
}
|
|
32
42
|
const duplicates: RegistryDuplicate[] = [];
|
|
33
|
-
for (const [reqId,
|
|
34
|
-
if (
|
|
43
|
+
for (const [reqId, occurrences] of byId) {
|
|
44
|
+
if (occurrences.length > 1) {
|
|
45
|
+
duplicates.push({
|
|
46
|
+
reqId,
|
|
47
|
+
files: [...new Set(occurrences.map((o) => o.fileName))].toSorted(),
|
|
48
|
+
occurrences,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
35
51
|
}
|
|
36
52
|
duplicates.sort((a, b) => a.reqId.localeCompare(b.reqId));
|
|
37
53
|
return { byId, duplicates };
|
|
@@ -1,19 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Embedded template table for single-file binaries.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* define values as raw expressions — the JSON is a valid object literal, so
|
|
12
|
-
* in the compiled binary the expression IS the table object itself, while a
|
|
13
|
-
* JSON string reaches these callers through the same expression in other
|
|
14
|
-
* engines or when force-set via env. Both forms are accepted below; every
|
|
15
|
-
* non-compiled run (source, npm package, Node) leaves the define unset and
|
|
16
|
-
* falls back to the real filesystem via TEMPLATES_ROOT.
|
|
4
|
+
* Compiled binaries receive the table through the same build-time define
|
|
5
|
+
* mechanism as the version (see apps/cli/src/cli-shared.ts — the define read
|
|
6
|
+
* lives at the CLI seam, core only receives already-resolved values). Bun
|
|
7
|
+
* inlines define values as raw expressions; both the JSON-string form and a
|
|
8
|
+
* pre-parsed object literal are accepted below. Every non-compiled run
|
|
9
|
+
* (source, npm package, Node) leaves the value unset and falls back to the
|
|
10
|
+
* real filesystem via TEMPLATES_ROOT.
|
|
17
11
|
*/
|
|
18
12
|
import type { TemplateIo } from './skills.ts';
|
|
19
13
|
|
|
@@ -37,9 +31,9 @@ export function resolveEmbeddedTable(value: unknown): Record<string, string> | u
|
|
|
37
31
|
return undefined;
|
|
38
32
|
}
|
|
39
33
|
|
|
40
|
-
/**
|
|
41
|
-
export function embeddedTemplates(): Record<string, string> | undefined {
|
|
42
|
-
return resolveEmbeddedTable(
|
|
34
|
+
/** Resolve the caller-passed define value; undefined when absent or malformed. */
|
|
35
|
+
export function embeddedTemplates(raw?: unknown): Record<string, string> | undefined {
|
|
36
|
+
return resolveEmbeddedTable(raw);
|
|
43
37
|
}
|
|
44
38
|
|
|
45
39
|
/**
|
package/src/templates/engine.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Template engine (init-generators capability, r17): nunjucks adapter with
|
|
3
|
-
* minijinja-compatible semantics used by
|
|
3
|
+
* minijinja-compatible semantics used by predecessor — string-only globals, undefined
|
|
4
4
|
* renders empty (Lenient), nested `unit(id)` expansion capped at 32, and
|
|
5
5
|
* trailing whitespace trimmed from the final product.
|
|
6
6
|
*/
|
|
@@ -22,22 +22,26 @@ export function renderWithUnits(
|
|
|
22
22
|
units: UnitRegistry,
|
|
23
23
|
vars: Record<string, string>,
|
|
24
24
|
depth = 0,
|
|
25
|
+
opts: { throwOnUndefined?: boolean } = {},
|
|
25
26
|
): string {
|
|
26
27
|
if (depth > MAX_UNIT_NESTING_DEPTH) {
|
|
27
28
|
throw new Error(`unit nesting exceeded ${MAX_UNIT_NESTING_DEPTH}`);
|
|
28
29
|
}
|
|
29
30
|
// minijinja keep_trailing_newline=false: a single trailing newline of the
|
|
30
|
-
// template SOURCE is stripped before rendering (
|
|
31
|
+
// template SOURCE is stripped before rendering (predecessor parity). \r? first —
|
|
31
32
|
// stripping \n alone would strand a trailing \r for CRLF sources.
|
|
32
33
|
const source = raw.replace(/\r?\n$/u, '');
|
|
33
|
-
const env = new nunjucks.Environment(null, {
|
|
34
|
+
const env = new nunjucks.Environment(null, {
|
|
35
|
+
autoescape: false,
|
|
36
|
+
throwOnUndefined: opts.throwOnUndefined === true,
|
|
37
|
+
});
|
|
34
38
|
for (const [key, value] of Object.entries(vars)) {
|
|
35
39
|
env.addGlobal(key, value);
|
|
36
40
|
}
|
|
37
41
|
env.addGlobal('unit', (id: string): string => {
|
|
38
42
|
const content = units.get(id);
|
|
39
43
|
if (content === undefined) throw new MissingUnitError(id);
|
|
40
|
-
return renderWithUnits(content, units, vars, depth + 1);
|
|
44
|
+
return renderWithUnits(content, units, vars, depth + 1, opts);
|
|
41
45
|
});
|
|
42
46
|
return env.renderString(source, {});
|
|
43
47
|
}
|
|
@@ -47,6 +51,7 @@ export function renderTemplate(
|
|
|
47
51
|
raw: string,
|
|
48
52
|
units: UnitRegistry,
|
|
49
53
|
vars: Record<string, string>,
|
|
54
|
+
opts: { throwOnUndefined?: boolean } = {},
|
|
50
55
|
): string {
|
|
51
|
-
return renderWithUnits(raw, units, vars, 0).trimEnd();
|
|
56
|
+
return renderWithUnits(raw, units, vars, 0, opts).trimEnd();
|
|
52
57
|
}
|
package/src/templates/locale.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Locale normalization + fallback chain (init-generators capability, r18).
|
|
3
|
-
* Port of
|
|
3
|
+
* Port of predecessor config.rs normalize_locale / locale_fallbacks.
|
|
4
4
|
*/
|
|
5
5
|
export function normalizeLocale(value: string): string {
|
|
6
6
|
const trimmed = value.trim();
|
package/src/templates/skills.ts
CHANGED
|
@@ -2,7 +2,7 @@ import type { SddConfig } from '../config/schema.ts';
|
|
|
2
2
|
import { renderTemplate, type UnitRegistry } from './engine.ts';
|
|
3
3
|
/**
|
|
4
4
|
* Skills rendering + init orchestration (init-generators capability, r19).
|
|
5
|
-
* Port of
|
|
5
|
+
* Port of predecessor templates.rs / update_skills.rs / init.rs semantics.
|
|
6
6
|
*/
|
|
7
7
|
import { localeFallbacks } from './locale.ts';
|
|
8
8
|
|
|
@@ -37,6 +37,7 @@ export const UNIT_FILES: readonly string[] = [
|
|
|
37
37
|
'skills/ethics-governance.md',
|
|
38
38
|
'spec/feature-contract.md',
|
|
39
39
|
'skills/structured-protocol.md',
|
|
40
|
+
'skills/cli-footer.md',
|
|
40
41
|
'workflow/archive-freeze-guidance.md',
|
|
41
42
|
];
|
|
42
43
|
|
|
@@ -48,7 +49,7 @@ export const ETHICS_KEYS: readonly string[] = [
|
|
|
48
49
|
'ethics.escalation_policy',
|
|
49
50
|
];
|
|
50
51
|
|
|
51
|
-
/** Framework-derived run_command (
|
|
52
|
+
/** Framework-derived run_command (predecessor config.rs effective_run_command). */
|
|
52
53
|
export function effectiveRunCommand(bdd: NonNullable<SddConfig['bdd']>): string {
|
|
53
54
|
if (bdd.run_command) return bdd.run_command;
|
|
54
55
|
switch (bdd.framework ?? '') {
|
|
@@ -65,15 +66,13 @@ export function effectiveRunCommand(bdd: NonNullable<SddConfig['bdd']>): string
|
|
|
65
66
|
}
|
|
66
67
|
}
|
|
67
68
|
|
|
68
|
-
/** All-string globals (
|
|
69
|
+
/** All-string globals (predecessor BTreeMap<String, String> semantics). */
|
|
69
70
|
export function buildTemplateVars(config: SddConfig, version: string): Record<string, string> {
|
|
70
71
|
const vars: Record<string, string> = { llman_version: version };
|
|
71
72
|
if (config.bdd) {
|
|
72
73
|
vars['bdd_enabled'] = 'true';
|
|
73
74
|
vars['bdd_framework'] = config.bdd.framework ?? '';
|
|
74
|
-
if (config.bdd.feature_dir) vars['bdd_feature_dir'] = config.bdd.feature_dir;
|
|
75
75
|
vars['bdd_run_command'] = effectiveRunCommand(config.bdd);
|
|
76
|
-
if (config.bdd.default_language) vars['bdd_default_language'] = config.bdd.default_language;
|
|
77
76
|
if (config.bdd.verify_prompt) vars['bdd_verify_prompt'] = config.bdd.verify_prompt;
|
|
78
77
|
}
|
|
79
78
|
const extras = new Set<string>(config.extra_skills ?? []);
|
|
@@ -1,6 +1,13 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { stageFor, walkActiveChangeDirs } from '../change/collect.ts';
|
|
2
|
+
import {
|
|
3
|
+
extractFrontmatter,
|
|
4
|
+
readBinding,
|
|
5
|
+
readNeedsSpecsChange,
|
|
6
|
+
specsLanded,
|
|
7
|
+
} from '../change/frontmatter.ts';
|
|
8
|
+
import { CLOSE_OUT_TASK_HINT, closeOutTaskLines, parseTaskCheckboxes } from '../change/tasks.ts';
|
|
2
9
|
/**
|
|
3
|
-
* Change-domain validation (
|
|
10
|
+
* Change-domain validation (predecessor `commands/validate.rs` change path parity):
|
|
4
11
|
* frontmatter/depends_on gates, design/tasks constraints, completeness stage
|
|
5
12
|
* INFO, pattern gate and task gates. IO + git injected (pure).
|
|
6
13
|
*/
|
|
@@ -10,7 +17,7 @@ export type ChangeIssueLevel = 'ERROR' | 'WARNING' | 'INFO';
|
|
|
10
17
|
|
|
11
18
|
export interface ChangeIssue {
|
|
12
19
|
level: ChangeIssueLevel;
|
|
13
|
-
/**
|
|
20
|
+
/** predecessor-style anchor (issue path), e.g. `proposal.md/frontmatter.depends_on`. */
|
|
14
21
|
path: string;
|
|
15
22
|
message: string;
|
|
16
23
|
}
|
|
@@ -25,7 +32,6 @@ export interface ChangeCheckInput {
|
|
|
25
32
|
|
|
26
33
|
export interface ChangeCheckConfig {
|
|
27
34
|
strict_defer?: boolean | null;
|
|
28
|
-
min_completion_ratio?: number | null;
|
|
29
35
|
change_id_pattern?: string | null;
|
|
30
36
|
}
|
|
31
37
|
|
|
@@ -38,6 +44,62 @@ export interface ChangeFsIoLite {
|
|
|
38
44
|
|
|
39
45
|
export const STAGE_ORDER = ['draft', 'designed', 'planned', 'full'] as const;
|
|
40
46
|
|
|
47
|
+
/**
|
|
48
|
+
* r73 dependency-reference resolution: `active` matches a leaf change dir by
|
|
49
|
+
* name (nested groups included — same caliber as the CLI change scan),
|
|
50
|
+
* `archived` matches an `archive/<YYYY-MM-DD>-<id>` dir OR a flat frozen card
|
|
51
|
+
* `archive/<YYYY-MM-DD>-<id>.yaml` exactly (a suffix like
|
|
52
|
+
* `2026-01-01-the-other` must NOT satisfy id `other`). A missing
|
|
53
|
+
* `archive/` dir reads as empty and never throws.
|
|
54
|
+
*/
|
|
55
|
+
function resolveChangeRef(
|
|
56
|
+
io: ChangeFsIoLite,
|
|
57
|
+
changesRoot: string,
|
|
58
|
+
id: string,
|
|
59
|
+
): 'active' | 'archived' | 'unknown' {
|
|
60
|
+
let active = false;
|
|
61
|
+
walkActiveChangeDirs(io, changesRoot, (_dir, name) => {
|
|
62
|
+
if (name === id) active = true;
|
|
63
|
+
});
|
|
64
|
+
if (active) return 'active';
|
|
65
|
+
const archive = `${changesRoot}/archive`;
|
|
66
|
+
const entries = io.exists(archive) && io.isDirectory(archive) ? io.listDir(archive) : [];
|
|
67
|
+
// `YYYY-MM-DD-` is 11 chars; the remainder must equal the id exactly.
|
|
68
|
+
const archived = entries.some((name) => {
|
|
69
|
+
if (!/^\d{4}-\d{2}-\d{2}-/u.test(name)) return false;
|
|
70
|
+
const base = name.endsWith('.yaml') ? name.slice(0, -'.yaml'.length) : name;
|
|
71
|
+
return base.slice(11) === id;
|
|
72
|
+
});
|
|
73
|
+
return archived ? 'archived' : 'unknown';
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* r87: global change-id uniqueness gate. Enumerates active change ids (leaf
|
|
78
|
+
* dir names), archived dir ids and frozen-card ids, and returns every id that
|
|
79
|
+
* appears more than once across the whole set. This is the cheap, enumerable
|
|
80
|
+
* form of the uniqueness contract: frozen ids are no longer hidden inside the
|
|
81
|
+
* cold backup, so collisions are checkable without thawing.
|
|
82
|
+
*/
|
|
83
|
+
export function checkGlobalChangeIdUniqueness(io: ChangeFsIoLite, changesRoot: string): string[] {
|
|
84
|
+
const archive = `${changesRoot}/archive`;
|
|
85
|
+
const entries = io.exists(archive) && io.isDirectory(archive) ? io.listDir(archive) : [];
|
|
86
|
+
const counts = new Map<string, number>();
|
|
87
|
+
walkActiveChangeDirs(io, changesRoot, (_dir, name) => {
|
|
88
|
+
counts.set(name, (counts.get(name) ?? 0) + 1);
|
|
89
|
+
});
|
|
90
|
+
for (const name of entries) {
|
|
91
|
+
if (!/^\d{4}-\d{2}-\d{2}-/u.test(name)) continue;
|
|
92
|
+
const base = name.endsWith('.yaml') ? name.slice(0, -'.yaml'.length) : name;
|
|
93
|
+
const id = base.slice(11);
|
|
94
|
+
if (id === '') continue;
|
|
95
|
+
counts.set(id, (counts.get(id) ?? 0) + 1);
|
|
96
|
+
}
|
|
97
|
+
return [...counts]
|
|
98
|
+
.filter(([, n]) => n > 1)
|
|
99
|
+
.map(([id]) => id)
|
|
100
|
+
.toSorted((a, b) => a.localeCompare(b));
|
|
101
|
+
}
|
|
102
|
+
|
|
41
103
|
export type StageGate = (typeof STAGE_ORDER)[number];
|
|
42
104
|
|
|
43
105
|
export interface ChangeCheckResult {
|
|
@@ -46,11 +108,14 @@ export interface ChangeCheckResult {
|
|
|
46
108
|
issues: ChangeIssue[];
|
|
47
109
|
}
|
|
48
110
|
|
|
49
|
-
/**
|
|
111
|
+
/**
|
|
112
|
+
* Legacy pure gate (r47 surface) — structural checks only (tasks, binding,
|
|
113
|
+
* pattern); the stage-ordinal branch was removed with the dual-caliber gate
|
|
114
|
+
* (the artifact-presence `--stage` gate lives in validateChange).
|
|
115
|
+
*/
|
|
50
116
|
export function checkChangeDoc(
|
|
51
117
|
input: ChangeCheckInput,
|
|
52
118
|
config: ChangeCheckConfig,
|
|
53
|
-
opts: { stage?: StageGate } = {},
|
|
54
119
|
): ChangeCheckResult {
|
|
55
120
|
const issues: ChangeIssue[] = [];
|
|
56
121
|
if (input.totalTasks > 0 && input.completedTasks < input.totalTasks) {
|
|
@@ -61,18 +126,6 @@ export function checkChangeDoc(
|
|
|
61
126
|
message: `${pending} unchecked task(s) in tasks.md`,
|
|
62
127
|
});
|
|
63
128
|
}
|
|
64
|
-
if (
|
|
65
|
-
config.min_completion_ratio !== undefined &&
|
|
66
|
-
config.min_completion_ratio !== null &&
|
|
67
|
-
input.totalTasks > 0 &&
|
|
68
|
-
input.completedTasks / input.totalTasks < config.min_completion_ratio
|
|
69
|
-
) {
|
|
70
|
-
issues.push({
|
|
71
|
-
level: 'ERROR',
|
|
72
|
-
path: 'tasks.md',
|
|
73
|
-
message: `completion ratio below archive.min_completion_ratio (${config.min_completion_ratio})`,
|
|
74
|
-
});
|
|
75
|
-
}
|
|
76
129
|
if (!input.hasBinding) {
|
|
77
130
|
issues.push({
|
|
78
131
|
level: 'WARNING',
|
|
@@ -81,23 +134,20 @@ export function checkChangeDoc(
|
|
|
81
134
|
});
|
|
82
135
|
}
|
|
83
136
|
if (config.change_id_pattern) {
|
|
84
|
-
|
|
85
|
-
|
|
137
|
+
try {
|
|
138
|
+
const re = new RegExp(config.change_id_pattern, 'u');
|
|
139
|
+
if (!re.test(input.name)) {
|
|
140
|
+
issues.push({
|
|
141
|
+
level: 'ERROR',
|
|
142
|
+
path: 'change-id',
|
|
143
|
+
message: `Change id '${input.name}' does not match change_id.pattern '${config.change_id_pattern}' (active changes only; archived changes are not re-checked).`,
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
} catch (error) {
|
|
86
147
|
issues.push({
|
|
87
148
|
level: 'ERROR',
|
|
88
149
|
path: 'change-id',
|
|
89
|
-
message: `
|
|
90
|
-
});
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
if (opts.stage !== undefined) {
|
|
94
|
-
const currentIdx = STAGE_ORDER.indexOf(input.stage);
|
|
95
|
-
const requiredIdx = STAGE_ORDER.indexOf(opts.stage);
|
|
96
|
-
if (currentIdx < requiredIdx) {
|
|
97
|
-
issues.push({
|
|
98
|
-
level: 'ERROR',
|
|
99
|
-
path: 'stage',
|
|
100
|
-
message: `stage \`${input.stage}\` is below the required \`${opts.stage}\``,
|
|
150
|
+
message: `change_id.pattern is not a valid regular expression: ${(error as Error).message}`,
|
|
101
151
|
});
|
|
102
152
|
}
|
|
103
153
|
}
|
|
@@ -108,17 +158,17 @@ export type ChangeValidationConfig = ChangeCheckConfig;
|
|
|
108
158
|
|
|
109
159
|
const COMPLETENESS: Record<string, string> = {
|
|
110
160
|
draft:
|
|
111
|
-
"Change is in 'draft' stage (next: add design.md + tasks.md, then `llman
|
|
161
|
+
"Change is in 'draft' stage (next: add design.md + tasks.md, then `llman-sdd change start <id>` to enter a feature branch)",
|
|
112
162
|
designed:
|
|
113
|
-
"Change is in 'designed' stage (next: add tasks.md to reach 'planned', then `llman
|
|
163
|
+
"Change is in 'designed' stage (next: add tasks.md to reach 'planned', then `llman-sdd change start` to enter feature branch and reach 'full')",
|
|
114
164
|
planned:
|
|
115
|
-
"Change is in 'planned' stage (next: `llman
|
|
116
|
-
full: "Change is bound and stage is 'full' (verify readiness via `llman
|
|
165
|
+
"Change is in 'planned' stage (next: `llman-sdd change start` to enter feature branch and reach 'full')",
|
|
166
|
+
full: "Change is bound and stage is 'full' (verify readiness via `llman-sdd show <id> --json` readyToImplement)",
|
|
117
167
|
};
|
|
118
168
|
|
|
119
169
|
/**
|
|
120
|
-
* File-aware change validation with
|
|
121
|
-
* command). `strict` escalates WARNING issues to ERROR (
|
|
170
|
+
* File-aware change validation with predecessor messages/paths (used by the validate
|
|
171
|
+
* command). `strict` escalates WARNING issues to ERROR (predecessor build_report).
|
|
122
172
|
* `git` (optional) enables the r63 completeness WARNINGs — Full-not-ready
|
|
123
173
|
* with skill guidance, per change.
|
|
124
174
|
*/
|
|
@@ -137,12 +187,15 @@ export function validateChange(
|
|
|
137
187
|
const dir = `${root}/llmanspec/changes/${id}/`;
|
|
138
188
|
const baseDir = `${root}/llmanspec/changes`;
|
|
139
189
|
const proposal = `${dir}proposal.md`;
|
|
190
|
+
// Probed once here: shared by the stage inference below and the --stage
|
|
191
|
+
// artifact gate at the end (predecessor issue order preserved).
|
|
192
|
+
const hasDesign = io.exists(`${dir}design.md`);
|
|
193
|
+
const hasTasks = io.exists(`${dir}tasks.md`);
|
|
140
194
|
if (!io.exists(proposal)) {
|
|
141
195
|
push('ERROR', 'proposal.md', 'Change is missing proposal.md.');
|
|
142
196
|
} else {
|
|
143
197
|
const text = io.readText(proposal);
|
|
144
|
-
const
|
|
145
|
-
const fm = fmMatch?.[1] ?? '';
|
|
198
|
+
const fm = extractFrontmatter(text) ?? '';
|
|
146
199
|
const fmLines = fm.split('\n').map((l) => l.trim());
|
|
147
200
|
|
|
148
201
|
for (const key of ['depends_on', 'blocks'] as const) {
|
|
@@ -180,13 +233,7 @@ export function validateChange(
|
|
|
180
233
|
);
|
|
181
234
|
continue;
|
|
182
235
|
}
|
|
183
|
-
|
|
184
|
-
const archived = `${baseDir}/archive`;
|
|
185
|
-
if (
|
|
186
|
-
!io.exists(`${depDir}/proposal.md`) &&
|
|
187
|
-
!io.exists(dir.replace(/\/[^/]+\/$/u, '/archive/')) &&
|
|
188
|
-
!io.listDir(archived).some((n) => n.endsWith(`-${item}`))
|
|
189
|
-
) {
|
|
236
|
+
if (resolveChangeRef(io, baseDir, item) === 'unknown') {
|
|
190
237
|
push(
|
|
191
238
|
'ERROR',
|
|
192
239
|
`proposal.md/frontmatter.${key}`,
|
|
@@ -196,7 +243,7 @@ export function validateChange(
|
|
|
196
243
|
}
|
|
197
244
|
}
|
|
198
245
|
|
|
199
|
-
// Unknown frontmatter field gate (
|
|
246
|
+
// Unknown frontmatter field gate (predecessor).
|
|
200
247
|
for (const l of fmLines) {
|
|
201
248
|
if (l === '' || l.startsWith('-') || l.startsWith('#')) continue;
|
|
202
249
|
const m = l.match(/^([a-zA-Z_][a-zA-Z0-9_]*)\s*:/u);
|
|
@@ -204,22 +251,14 @@ export function validateChange(
|
|
|
204
251
|
push(
|
|
205
252
|
'ERROR',
|
|
206
253
|
'proposal.md/frontmatter',
|
|
207
|
-
`proposal.md frontmatter has unknown field '${m[1]}'; allowed fields are: ${ALLOWED_FIELDS.join(', ')}. Stage is inferred from on-disk artifacts (run \`llman
|
|
254
|
+
`proposal.md frontmatter has unknown field '${m[1]}'; allowed fields are: ${ALLOWED_FIELDS.join(', ')}. Stage is inferred from on-disk artifacts (run \`llman-sdd show\` / \`llman-sdd list\`); do not store lifecycle state in frontmatter.`,
|
|
208
255
|
);
|
|
209
256
|
}
|
|
210
257
|
}
|
|
211
258
|
|
|
212
|
-
const hasDesign = io.exists(`${dir}design.md`);
|
|
213
|
-
const hasTasks = io.exists(`${dir}tasks.md`);
|
|
214
259
|
const binding = readBinding(text);
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
? binding !== null
|
|
218
|
-
? 'full'
|
|
219
|
-
: 'planned'
|
|
220
|
-
: hasDesign
|
|
221
|
-
? 'designed'
|
|
222
|
-
: 'draft';
|
|
260
|
+
// stageFor (change/collect) is the monotonic stage SSOT (r34).
|
|
261
|
+
const stage = stageFor(hasDesign, hasTasks, binding !== null);
|
|
223
262
|
|
|
224
263
|
if (hasTasks && !hasDesign) {
|
|
225
264
|
push(
|
|
@@ -230,16 +269,7 @@ export function validateChange(
|
|
|
230
269
|
}
|
|
231
270
|
|
|
232
271
|
if (hasTasks) {
|
|
233
|
-
const
|
|
234
|
-
let total = 0;
|
|
235
|
-
let completed = 0;
|
|
236
|
-
for (const line of tasks.split('\n')) {
|
|
237
|
-
const m = line.match(/^\s*-\s+\[( |x|X)\]/u);
|
|
238
|
-
if (m) {
|
|
239
|
-
total += 1;
|
|
240
|
-
if (m[1] !== ' ') completed += 1;
|
|
241
|
-
}
|
|
242
|
-
}
|
|
272
|
+
const { completed, total, pendingLines } = parseTaskCheckboxes(io.readText(`${dir}tasks.md`));
|
|
243
273
|
if (total > 0 && completed < total) {
|
|
244
274
|
const n = total - completed;
|
|
245
275
|
push(
|
|
@@ -248,31 +278,41 @@ export function validateChange(
|
|
|
248
278
|
`${n} unchecked task(s) in tasks.md`,
|
|
249
279
|
);
|
|
250
280
|
}
|
|
281
|
+
// D9/B23 (active changes only — validate domain): an unchecked task whose
|
|
282
|
+
// title starts with a close-out verb is a pipeline-step pseudo-task; the
|
|
283
|
+
// task gate requires every task checked, so it can never legitimately
|
|
284
|
+
// stay as a task. Match on the leading verb only, so "fix finalize task
|
|
285
|
+
// gate" (a task ABOUT close-out) is not flagged.
|
|
286
|
+
for (const line of closeOutTaskLines(pendingLines)) {
|
|
287
|
+
const title = line.replace(/^-\s+\[ \]\s*/u, '').trim();
|
|
288
|
+
push(
|
|
289
|
+
'WARNING',
|
|
290
|
+
'tasks',
|
|
291
|
+
`task "${title}" looks like a close-out step; ${CLOSE_OUT_TASK_HINT}`,
|
|
292
|
+
);
|
|
293
|
+
}
|
|
251
294
|
}
|
|
252
295
|
|
|
253
|
-
// completeness INFO (
|
|
296
|
+
// completeness INFO (predecessor surface).
|
|
254
297
|
push('INFO', 'completeness', COMPLETENESS[stage] ?? '');
|
|
255
298
|
|
|
256
|
-
// r63: Full-but-not-ready WARNING with skill guidance (
|
|
299
|
+
// r63: Full-but-not-ready WARNING with skill guidance (predecessor r1 surface).
|
|
257
300
|
if (opts.git !== undefined && binding !== null && stage === 'full') {
|
|
258
|
-
const needs =
|
|
259
|
-
|
|
260
|
-
? text.match(/^needs_specs_change:\s*(true|false)\s*$/mu)?.[1] === 'true'
|
|
261
|
-
: true;
|
|
262
|
-
const touched =
|
|
263
|
-
opts.git.runOpt(['diff', '--name-only', `${binding.baseBranch}...${binding.branch}`]) ?? '';
|
|
264
|
-
const landed = touched.includes('llmanspec/specs/');
|
|
301
|
+
const needs = readNeedsSpecsChange(fm);
|
|
302
|
+
const landed = specsLanded(opts.git, binding);
|
|
265
303
|
if (!landed && needs) {
|
|
266
304
|
push(
|
|
267
305
|
'WARNING',
|
|
268
306
|
'proposal.md',
|
|
269
|
-
`specs not landed: change bound to \`${binding.branch}\` but no changes under \`llmanspec/specs/\` on its bound branch. Edit live specs there and commit (or set \`needs_specs_change
|
|
307
|
+
`specs not landed: change bound to \`${binding.branch}\` but no changes under \`llmanspec/specs/\` on its bound branch. Edit live specs there and commit (or set \`needs_specs_change\` to false in the frontmatter if this change has no live contract edits). Skill: llman-sdd-propose — do NOT re-run change start when already attached; start llman-sdd-apply once the specs-landed gate passes (see \`llman-sdd show <id>\` gateChecks).`,
|
|
270
308
|
);
|
|
271
309
|
}
|
|
272
310
|
}
|
|
273
311
|
}
|
|
274
312
|
|
|
275
|
-
// pattern gate (
|
|
313
|
+
// pattern gate (predecessor change-id path). loadConfig compiles the pattern first
|
|
314
|
+
// on the CLI path; this branch defends direct core callers that build a
|
|
315
|
+
// ChangeCheckConfig without going through loadConfig.
|
|
276
316
|
if (config.change_id_pattern) {
|
|
277
317
|
try {
|
|
278
318
|
const re = new RegExp(config.change_id_pattern, 'u');
|
|
@@ -280,18 +320,20 @@ export function validateChange(
|
|
|
280
320
|
push(
|
|
281
321
|
'ERROR',
|
|
282
322
|
'change-id',
|
|
283
|
-
`Change id '${id}' does not match change_id.pattern '${config.change_id_pattern}' (
|
|
323
|
+
`Change id '${id}' does not match change_id.pattern '${config.change_id_pattern}' (active changes only; archived changes are not re-checked).`,
|
|
284
324
|
);
|
|
285
325
|
}
|
|
286
|
-
} catch {
|
|
287
|
-
|
|
326
|
+
} catch (error) {
|
|
327
|
+
push(
|
|
328
|
+
'ERROR',
|
|
329
|
+
'change-id',
|
|
330
|
+
`change_id.pattern is not a valid regular expression: ${(error as Error).message}`,
|
|
331
|
+
);
|
|
288
332
|
}
|
|
289
333
|
}
|
|
290
334
|
|
|
291
|
-
// stage gate (
|
|
335
|
+
// stage gate (predecessor --stage: artifact presence per gate).
|
|
292
336
|
if (opts.stage !== undefined) {
|
|
293
|
-
const hasDesign = io.exists(`${dir}design.md`);
|
|
294
|
-
const hasTasks = io.exists(`${dir}tasks.md`);
|
|
295
337
|
if (opts.stage === 'designed' && !hasDesign) {
|
|
296
338
|
push('ERROR', 'design.md', `Stage forced to 'designed' but design.md is missing`);
|
|
297
339
|
}
|
|
@@ -320,22 +362,3 @@ const ALLOWED_FIELDS = [
|
|
|
320
362
|
'base_sha',
|
|
321
363
|
'needs_specs_change',
|
|
322
364
|
];
|
|
323
|
-
|
|
324
|
-
export interface PlaceholderTarget {
|
|
325
|
-
featureDir: string;
|
|
326
|
-
featureName: string;
|
|
327
|
-
featurePath: string;
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
/** True when run_command carries any r48 placeholder. */
|
|
331
|
-
export function hasPlaceholders(runCommand: string): boolean {
|
|
332
|
-
return /\{feature_dir\}|\{feature_name\}|\{feature_path\}/u.test(runCommand);
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
/** Expand {feature_dir}/{feature_name}/{feature_path} for one target (r48). */
|
|
336
|
-
export function expandRunCommand(runCommand: string, target: PlaceholderTarget): string {
|
|
337
|
-
return runCommand
|
|
338
|
-
.replaceAll('{feature_dir}', target.featureDir)
|
|
339
|
-
.replaceAll('{feature_name}', target.featureName)
|
|
340
|
-
.replaceAll('{feature_path}', target.featurePath);
|
|
341
|
-
}
|