freeharness 0.4.1 → 0.4.2
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/README.md +8 -7
- package/canonical/HARVEST.yaml +25 -14
- package/canonical/PROVENANCE.yaml +1 -1
- package/canonical/agents/README.md +77 -36
- package/canonical/agents/agentic-architect.md +2 -1
- package/canonical/agents/database-reviewer.md +6 -8
- package/canonical/agents/explorer-ai-engineer-analyst.md +2 -6
- package/canonical/agents/implementer.md +30 -0
- package/canonical/agents/pr-reviewer.md +9 -11
- package/canonical/agents/python-reviewer.md +7 -9
- package/canonical/agents/report-formatter.md +7 -3
- package/canonical/agents/security-reviewer.md +7 -8
- package/canonical/agents/test-writer.md +5 -2
- package/canonical/hooks/cost-prices.json +11 -9
- package/canonical/hooks/freeharness-config-guard.mjs +8072 -87
- package/canonical/hooks/freeharness-context-monitor.mjs +88 -54
- package/canonical/hooks/freeharness-converge.mjs +78 -23
- package/canonical/hooks/freeharness-cost-tracker.mjs +1129 -218
- package/canonical/hooks/freeharness-memory-guard.mjs +20 -23
- package/canonical/hooks/freeharness-precompact.mjs +217 -87
- package/canonical/hooks/freeharness-quota-tap.mjs +173 -0
- package/canonical/hooks/freeharness-reload-context.mjs +197 -0
- package/canonical/hooks/freeharness-repeat-tool.mjs +1 -1
- package/canonical/hooks/freeharness-sdd-context.mjs +970 -0
- package/canonical/hooks/freeharness-session-start +297 -59
- package/canonical/hooks/hooks.claude-code.json +1 -1
- package/canonical/hooks/hooks.config-guard.claude-code.json +3 -2
- package/canonical/hooks/hooks.context-monitor.claude-code.json +1 -1
- package/canonical/hooks/hooks.converge.claude-code.json +1 -1
- package/canonical/hooks/hooks.memory-guard.claude-code.json +3 -2
- package/canonical/hooks/hooks.reload-context.claude-code.json +15 -0
- package/canonical/openspec/schema.spec-driven.yaml +29 -8
- package/canonical/openspec/templates/follow-ups.md +3 -2
- package/canonical/openspec/templates/proposal.md +1 -1
- package/canonical/openspec/templates/tasks.md +8 -0
- package/canonical/rules/CLAUDE.md +5 -4
- package/canonical/rules/routing.md +10 -9
- package/canonical/skills/agent-evaluation/SKILL.md +17 -6
- package/canonical/skills/agent-evaluation/evals/cases.yaml +38 -23
- package/canonical/skills/agentic-architecture/SKILL.md +4 -0
- package/canonical/skills/agentic-architecture/evals/cases.yaml +27 -0
- package/canonical/skills/code-review/SKILL.md +9 -2
- package/canonical/skills/code-review/evals/cases.yaml +1118 -27
- package/canonical/skills/git-workflow/SKILL.md +2 -0
- package/canonical/skills/local-code/SKILL.md +2 -2
- package/canonical/skills/local-code/evals/cases.yaml +1 -1
- package/canonical/skills/report-formatting/SKILL.md +10 -2
- package/canonical/skills/report-formatting/reference/ARCHITECTURE.md +3 -3
- package/canonical/skills/report-formatting/reference/HELPERS.md +10 -5
- package/canonical/skills/report-formatting/reference/RELEASE.md +1 -2
- package/canonical/skills/report-formatting/reference/TEMPLATES.md +3 -2
- package/canonical/skills/report-formatting/reference/authoring.md +25 -37
- package/canonical/skills/report-formatting/scripts/build-architecture.js +1 -1
- package/canonical/skills/report-formatting/scripts/build-md-claro.js +5 -8
- package/canonical/skills/report-formatting/scripts/check-env.js +0 -4
- package/canonical/skills/report-formatting/scripts/docmodel.js +3 -11
- package/canonical/skills/report-formatting/scripts/example-build.js +8 -10
- package/canonical/skills/report-formatting/scripts/example_build_ArchitectureReview_docx.js +8 -10
- package/canonical/skills/report-formatting/scripts/example_build_ExecutiveReport_docx.js +8 -10
- package/canonical/skills/report-formatting/scripts/theme-claro.js +39 -10
- package/canonical/skills/subagent-driven-development/SKILL.md +35 -28
- package/canonical/skills/subagent-driven-development/evals/cases.yaml +3496 -19
- package/canonical/skills/subagent-driven-development/reference/brief.md +185 -35
- package/canonical/skills/subagent-driven-development/reference/handoff.md +82 -2
- package/canonical/skills/subagent-driven-development/reference/loop.md +57 -0
- package/canonical/skills/subagent-driven-development/reference/trees.md +40 -0
- package/canonical/skills/subagent-driven-development/reference/workspace.md +31 -0
- package/canonical/skills/subagent-driven-development/scripts/validate-handoff.mjs +4771 -326
- package/dist/adapters/harness/claude-code.d.ts +19 -3
- package/dist/adapters/harness/claude-code.js +204 -39
- package/dist/adapters/harness/opencode.js +16 -2
- package/dist/adapters/harness/plugin.js +28 -17
- package/dist/adapters/memory/doctor.d.ts +174 -29
- package/dist/adapters/memory/doctor.js +758 -134
- package/dist/adapters/memory/migrate.d.ts +95 -0
- package/dist/adapters/memory/migrate.js +497 -0
- package/dist/adapters/memory/paths.d.ts +22 -0
- package/dist/adapters/memory/paths.js +85 -0
- package/dist/adapters/memory/vendored.d.ts +56 -29
- package/dist/adapters/memory/vendored.js +191 -154
- package/dist/adapters/sync/diff.js +4 -1
- package/dist/cli/cleanup-prompt.d.ts +35 -0
- package/dist/cli/cleanup-prompt.js +68 -0
- package/dist/cli/cleanup.d.ts +22 -24
- package/dist/cli/cleanup.js +182 -68
- package/dist/cli/cost-quota.d.ts +158 -0
- package/dist/cli/cost-quota.js +645 -0
- package/dist/cli/cost.d.ts +408 -46
- package/dist/cli/cost.js +1731 -57
- package/dist/cli/doctor.d.ts +52 -13
- package/dist/cli/doctor.js +193 -44
- package/dist/cli/hard-cleanup.d.ts +93 -0
- package/dist/cli/hard-cleanup.js +552 -0
- package/dist/cli/install-facts.d.ts +28 -0
- package/dist/cli/install-facts.js +66 -0
- package/dist/cli/json-key-edit.d.ts +18 -0
- package/dist/cli/json-key-edit.js +118 -0
- package/dist/cli/memory-import.d.ts +74 -0
- package/dist/cli/memory-import.js +469 -0
- package/dist/cli/plugin-validate.d.ts +12 -0
- package/dist/cli/plugin-validate.js +43 -0
- package/dist/cli/uninstall.d.ts +1 -1
- package/dist/cli/uninstall.js +2 -2
- package/dist/cli/upstream-launchers.d.ts +49 -0
- package/dist/cli/upstream-launchers.js +287 -0
- package/dist/index.js +247 -70
- package/dist/infra/fsutil.d.ts +31 -3
- package/dist/infra/fsutil.js +69 -6
- package/dist/infra/manifest.d.ts +2 -1
- package/dist/infra/manifest.js +3 -2
- package/dist/infra/prices.d.ts +18 -0
- package/dist/infra/prices.js +64 -0
- package/node_modules/@freeharness/core/dist/tiers.d.ts +10 -1
- package/node_modules/@freeharness/core/dist/tiers.js +24 -2
- package/node_modules/@freeharness/core/package.json +1 -1
- package/node_modules/@freeharness/sdd/PROVENANCE.yaml +279 -2
- package/node_modules/@freeharness/sdd/dist/src/bin/openspec.js +34 -8
- package/node_modules/@freeharness/sdd/dist/src/shim/archive.d.ts +7 -6
- package/node_modules/@freeharness/sdd/dist/src/shim/archive.js +72 -64
- package/node_modules/@freeharness/sdd/dist/src/shim/follow-up.d.ts +48 -16
- package/node_modules/@freeharness/sdd/dist/src/shim/follow-up.js +168 -70
- package/node_modules/@freeharness/sdd/dist/src/shim/instructions.d.ts +36 -6
- package/node_modules/@freeharness/sdd/dist/src/shim/instructions.js +110 -49
- package/node_modules/@freeharness/sdd/dist/src/shim/list.d.ts +1 -1
- package/node_modules/@freeharness/sdd/dist/src/shim/list.js +57 -14
- package/node_modules/@freeharness/sdd/dist/src/shim/nested-change.d.ts +7 -0
- package/node_modules/@freeharness/sdd/dist/src/shim/nested-change.js +118 -0
- package/node_modules/@freeharness/sdd/dist/src/shim/new-change.js +3 -1
- package/node_modules/@freeharness/sdd/dist/src/shim/proposal-capabilities.d.ts +12 -0
- package/node_modules/@freeharness/sdd/dist/src/shim/proposal-capabilities.js +94 -0
- package/node_modules/@freeharness/sdd/dist/src/shim/shared.d.ts +17 -0
- package/node_modules/@freeharness/sdd/dist/src/shim/shared.js +182 -1
- package/node_modules/@freeharness/sdd/dist/src/shim/status.d.ts +2 -2
- package/node_modules/@freeharness/sdd/dist/src/shim/status.js +34 -5
- package/node_modules/@freeharness/sdd/dist/src/shim/validate.d.ts +9 -0
- package/node_modules/@freeharness/sdd/dist/src/shim/validate.js +49 -41
- package/node_modules/@freeharness/sdd/dist/src/shim/warnings.d.ts +21 -0
- package/node_modules/@freeharness/sdd/dist/src/shim/warnings.js +66 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/apply/line-endings.d.ts +4 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/apply/line-endings.js +17 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/apply/specs-apply.js +95 -22
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/change-status-policy.d.ts +6 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/change-status-policy.js +14 -6
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/index.d.ts +1 -1
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/index.js +1 -1
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/outputs.d.ts +3 -3
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/outputs.js +24 -4
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/task-progress.js +4 -7
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/types.js +13 -1
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/apply-change.js +21 -6
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/archive-change.js +80 -19
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/explore.js +42 -22
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/project-root.d.ts +32 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/project-root.js +39 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/propose.js +16 -9
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/sync-specs.js +36 -1
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/update-change.js +65 -30
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/constants.d.ts +1 -1
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/constants.js +1 -1
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/parsers/change-parser.d.ts +8 -13
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/parsers/change-parser.js +79 -102
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/parsers/markdown-parser.js +4 -9
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/parsers/requirement-blocks.d.ts +50 -10
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/parsers/requirement-blocks.js +165 -37
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/parsers/requirement-text.d.ts +24 -3
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/parsers/requirement-text.js +55 -8
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/parsers/spec-structure.js +4 -1
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/purpose-placeholder.js +19 -9
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/spec-discovery.d.ts +10 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/spec-discovery.js +115 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/task-lines.d.ts +3 -3
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/task-lines.js +20 -20
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/validator.d.ts +6 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/validator.js +75 -14
- package/node_modules/@freeharness/sdd/package.json +1 -1
- package/node_modules/@freeharness/sdd/vendor/openspec/assets/schema.upstream.yaml +47 -7
- package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-apply-change/SKILL.md +25 -7
- package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-archive-change/SKILL.md +50 -11
- package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-bulk-archive-change/SKILL.md +48 -10
- package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-continue-change/SKILL.md +27 -7
- package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-explore/SKILL.md +25 -12
- package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-ff-change/SKILL.md +17 -5
- package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-new-change/SKILL.md +12 -1
- package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-onboard/SKILL.md +47 -33
- package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-propose/SKILL.md +17 -5
- package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-sync-specs/SKILL.md +29 -1
- package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-update-change/SKILL.md +31 -13
- package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-verify-change/SKILL.md +75 -27
- package/package.json +6 -4
- package/provenance/canonical.PROVENANCE.yaml +1 -1
- package/provenance/memory.PROVENANCE.yaml +458 -6
- package/provenance/sdd.PROVENANCE.yaml +279 -2
- package/canonical/hooks/freeharness-auto-memory.mjs +0 -158
- package/canonical/hooks/hooks.auto-memory.claude-code.json +0 -16
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// The capabilities a proposal promises a delta spec for, read from its
|
|
2
|
+
// `## Capabilities` section, and the ones whose `specs/<capability>/spec.md`
|
|
3
|
+
// is not on disk yet. An interrupted propose leaves exactly that gap, which
|
|
4
|
+
// the specs glob alone cannot see once a single delta exists.
|
|
5
|
+
import * as fs from 'node:fs';
|
|
6
|
+
import * as path from 'node:path';
|
|
7
|
+
const COUNTED_LABELS = ['new capabilities:', 'modified capabilities:'];
|
|
8
|
+
function stripHtmlComments(markdown) {
|
|
9
|
+
let out = '';
|
|
10
|
+
let from = 0;
|
|
11
|
+
for (;;) {
|
|
12
|
+
const open = markdown.indexOf('<!--', from);
|
|
13
|
+
if (open === -1)
|
|
14
|
+
return out + markdown.slice(from);
|
|
15
|
+
out += markdown.slice(from, open);
|
|
16
|
+
const close = markdown.indexOf('-->', open + 4);
|
|
17
|
+
if (close === -1)
|
|
18
|
+
return out;
|
|
19
|
+
from = close + 3;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
function firstBacktickedName(bullet) {
|
|
23
|
+
const open = bullet.indexOf('`');
|
|
24
|
+
if (open === -1)
|
|
25
|
+
return '';
|
|
26
|
+
const close = bullet.indexOf('`', open + 1);
|
|
27
|
+
return close === -1 ? '' : bullet.slice(open + 1, close).trim();
|
|
28
|
+
}
|
|
29
|
+
function isPlaceholder(name) {
|
|
30
|
+
return name === '' || name.toLowerCase() === 'none' || (name.startsWith('<') && name.endsWith('>'));
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Names in document order, deduplicated. Only top-level `- ` bullets count, so
|
|
34
|
+
* a sub-bullet or a wrapped description line naming code never reads as a
|
|
35
|
+
* capability; any other unindented label or subheading closes the counted list.
|
|
36
|
+
*/
|
|
37
|
+
export function parseProposalCapabilities(markdown) {
|
|
38
|
+
const names = [];
|
|
39
|
+
const seen = new Set();
|
|
40
|
+
let inSection = false;
|
|
41
|
+
let counting = false;
|
|
42
|
+
for (const rawLine of stripHtmlComments(markdown).split('\n')) {
|
|
43
|
+
const line = rawLine.trimEnd();
|
|
44
|
+
if (line.startsWith('# ') || line.startsWith('## ')) {
|
|
45
|
+
inSection = line.slice(line.indexOf(' ') + 1).trim().toLowerCase() === 'capabilities';
|
|
46
|
+
counting = false;
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
if (!inSection)
|
|
50
|
+
continue;
|
|
51
|
+
if (line.startsWith('- ')) {
|
|
52
|
+
const name = counting ? firstBacktickedName(line) : '';
|
|
53
|
+
if (!isPlaceholder(name) && !seen.has(name)) {
|
|
54
|
+
seen.add(name);
|
|
55
|
+
names.push(name);
|
|
56
|
+
}
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
if (line.length === 0 || /^\s/.test(line))
|
|
60
|
+
continue;
|
|
61
|
+
const label = line.toLowerCase();
|
|
62
|
+
if (COUNTED_LABELS.some((counted) => label.startsWith(counted)))
|
|
63
|
+
counting = true;
|
|
64
|
+
else if (label.startsWith('#') || label.endsWith(':'))
|
|
65
|
+
counting = false;
|
|
66
|
+
}
|
|
67
|
+
return names;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Listed capabilities without a `spec.md` under the change's `specs/`. A name
|
|
71
|
+
* that resolves outside `specs/` (an untrusted `../` path) can hold no delta
|
|
72
|
+
* there, so it counts as missing instead of probing the path.
|
|
73
|
+
*/
|
|
74
|
+
export function missingCapabilitySpecs(changeDir) {
|
|
75
|
+
let proposal;
|
|
76
|
+
try {
|
|
77
|
+
proposal = fs.readFileSync(path.join(changeDir, 'proposal.md'), 'utf-8');
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
return [];
|
|
81
|
+
}
|
|
82
|
+
const specsDir = path.resolve(changeDir, 'specs');
|
|
83
|
+
return parseProposalCapabilities(proposal).filter((capability) => {
|
|
84
|
+
const specFile = path.resolve(specsDir, capability, 'spec.md');
|
|
85
|
+
if (!specFile.startsWith(specsDir + path.sep))
|
|
86
|
+
return true;
|
|
87
|
+
try {
|
|
88
|
+
return !fs.statSync(specFile).isFile();
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
return true;
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
}
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
/** Emits a value as pretty JSON on stdout (the single-document `--json` contract). */
|
|
2
2
|
export declare function printJson(payload: unknown): void;
|
|
3
|
+
/** Flattens repo-controlled text to one line, so a newline cannot forge a sibling instruction line. */
|
|
4
|
+
export declare function sanitizeInline(value: string, maxLength?: number): string;
|
|
5
|
+
export declare function escapeEnvelopeTags(value: string): string;
|
|
6
|
+
/** Attribute values are ids and directory names, never prose: escape every metacharacter. */
|
|
7
|
+
export declare function escapeEnvelopeAttribute(value: string): string;
|
|
3
8
|
/**
|
|
4
9
|
* A task's numbering label for a message (the leading `N.N` of its
|
|
5
10
|
* description), falling back to its line number when the description carries
|
|
@@ -54,3 +59,15 @@ export declare function resolveChangeId(projectRoot: string, id: string): 'in-fl
|
|
|
54
59
|
export declare function validateChangeExists(changeName: string | undefined, projectRoot: string): string;
|
|
55
60
|
/** Validates that a schema resolves; throws with the available list otherwise. */
|
|
56
61
|
export declare function validateSchemaExists(schemaName: string, projectRoot?: string): string;
|
|
62
|
+
/**
|
|
63
|
+
* Warns when every file a change's schema tracks as tasks contains list
|
|
64
|
+
* items but not one checkbox. Undefined when the schema tracks no task file
|
|
65
|
+
* at all (a bare top-level `tasks.md` no artifact declares is not read by
|
|
66
|
+
* this check; warning about it would be a guess about a file the tool never
|
|
67
|
+
* opens), when the tracked set has at least one checkbox anywhere, when a
|
|
68
|
+
* tracked file could not be read (it may hold the checkboxes the unreadable
|
|
69
|
+
* file lost), or when no tracked file carries a bare list item either. The
|
|
70
|
+
* message opens with `<file relative to changeDir>:<line of the first bare
|
|
71
|
+
* list item>`.
|
|
72
|
+
*/
|
|
73
|
+
export declare function findMissingTaskCheckboxWarning(changeDir: string, projectRoot: string): string | undefined;
|
|
@@ -6,11 +6,44 @@
|
|
|
6
6
|
// src/root-resolver.ts).
|
|
7
7
|
import * as fs from 'node:fs';
|
|
8
8
|
import * as path from 'node:path';
|
|
9
|
-
import { getSchemaDir, listSchemas } from '../../vendor/openspec/engine/index.js';
|
|
9
|
+
import { getSchemaDir, listSchemas, parseTaskLines } from '../../vendor/openspec/engine/index.js';
|
|
10
|
+
import { resolveTaskFilesForChange } from '../../vendor/openspec/engine/task-progress.js';
|
|
11
|
+
import { describeNestedChange, findNestedChangesIn } from './nested-change.js';
|
|
10
12
|
/** Emits a value as pretty JSON on stdout (the single-document `--json` contract). */
|
|
11
13
|
export function printJson(payload) {
|
|
12
14
|
console.log(JSON.stringify(payload, null, 2));
|
|
13
15
|
}
|
|
16
|
+
/** Flattens repo-controlled text to one line, so a newline cannot forge a sibling instruction line. */
|
|
17
|
+
export function sanitizeInline(value, maxLength = 300) {
|
|
18
|
+
const flattened = value.replace(/[\u0000-\u001f\u007f]+/g, ' ').trim();
|
|
19
|
+
return flattened.length > maxLength ? `${flattened.slice(0, maxLength)}…` : flattened;
|
|
20
|
+
}
|
|
21
|
+
/** The tags `printInstructionsText` uses to frame its blocks; a block ends only at its own closing tag. */
|
|
22
|
+
const ENVELOPE_TAGS = [
|
|
23
|
+
'artifact',
|
|
24
|
+
'dependencies',
|
|
25
|
+
'dependency',
|
|
26
|
+
'description',
|
|
27
|
+
'instruction',
|
|
28
|
+
'output',
|
|
29
|
+
'path',
|
|
30
|
+
'template',
|
|
31
|
+
'task',
|
|
32
|
+
'unlocks',
|
|
33
|
+
'warning',
|
|
34
|
+
];
|
|
35
|
+
// Requires real `name="value"` syntax, not bare text: schema guidance writes
|
|
36
|
+
// `<task ids>`-style placeholders that must not read as a forged opener.
|
|
37
|
+
const ENVELOPE_TAG = new RegExp(`<(/?)(${ENVELOPE_TAGS.join('|')})((?:\\s+[A-Za-z_:][\\w:.-]*="[^"]*")*)\\s*>`, 'gi');
|
|
38
|
+
// Narrow on purpose: escaping every angle bracket also works, but mangles
|
|
39
|
+
// ordinary content like `### Requirement: <name>`.
|
|
40
|
+
export function escapeEnvelopeTags(value) {
|
|
41
|
+
return value.replace(ENVELOPE_TAG, (_match, slash, tag, attrs) => `<${slash}${tag}${attrs ?? ''}>`);
|
|
42
|
+
}
|
|
43
|
+
/** Attribute values are ids and directory names, never prose: escape every metacharacter. */
|
|
44
|
+
export function escapeEnvelopeAttribute(value) {
|
|
45
|
+
return value.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
46
|
+
}
|
|
14
47
|
const TASK_ID = /^(\d+(?:\.\d+)+(?:[A-Za-z]+)?)(?=\s|$)/;
|
|
15
48
|
/**
|
|
16
49
|
* A task's numbering label for a message (the leading `N.N` of its
|
|
@@ -127,6 +160,13 @@ export function validateChangeExists(changeName, projectRoot) {
|
|
|
127
160
|
}
|
|
128
161
|
throw new Error(`Change '${changeName}' not found. Available changes:\n ${available.join('\n ')}`);
|
|
129
162
|
}
|
|
163
|
+
// The directory exists but is a namespace folder wrapping nested change
|
|
164
|
+
// directories. Every artifact lookup below it would report "not started"
|
|
165
|
+
// for work that is in fact there, so say what is actually wrong instead.
|
|
166
|
+
const nested = findNestedChangesIn(changesDirFor(projectRoot), changeName);
|
|
167
|
+
if (nested) {
|
|
168
|
+
throw new Error(describeNestedChange(nested));
|
|
169
|
+
}
|
|
130
170
|
return changeName;
|
|
131
171
|
}
|
|
132
172
|
/** Validates that a schema resolves; throws with the available list otherwise. */
|
|
@@ -138,3 +178,144 @@ export function validateSchemaExists(schemaName, projectRoot) {
|
|
|
138
178
|
}
|
|
139
179
|
return schemaName;
|
|
140
180
|
}
|
|
181
|
+
// ---------------------------------------------------------------------------
|
|
182
|
+
// Tracked tasks with no checkbox.
|
|
183
|
+
//
|
|
184
|
+
// Progress counts checkboxes and nothing else: a tasks file written as plain
|
|
185
|
+
// bullets or a numbered list reads as empty to `status`/`instructions apply`
|
|
186
|
+
// and leaves `archive` with no incomplete task to gate on, though the file
|
|
187
|
+
// carries unfinished work to a reader. `findMissingTaskCheckboxWarning`
|
|
188
|
+
// reports the first bare list item once every file the change tracks as
|
|
189
|
+
// tasks has zero checkboxes.
|
|
190
|
+
// ---------------------------------------------------------------------------
|
|
191
|
+
/** A list item carrying text but no checkbox: `- item`, `* item`, `+ item`, `1. item`, `1) item`. */
|
|
192
|
+
const LIST_ITEM = /^\s*(?:[-*+]|\d{1,9}[.)])\s+\S/;
|
|
193
|
+
/**
|
|
194
|
+
* The indent, in visual columns, at which a top-level line stops being
|
|
195
|
+
* content and becomes an indented code block per CommonMark. A tab counts as
|
|
196
|
+
* four.
|
|
197
|
+
*/
|
|
198
|
+
const CODE_BLOCK_COLUMN = 4;
|
|
199
|
+
/** Leading whitespace of a line in visual columns, a tab counting as four. */
|
|
200
|
+
function indentColumns(line) {
|
|
201
|
+
let column = 0;
|
|
202
|
+
for (const char of line) {
|
|
203
|
+
if (char === ' ')
|
|
204
|
+
column += 1;
|
|
205
|
+
else if (char === '\t')
|
|
206
|
+
column += CODE_BLOCK_COLUMN - (column % CODE_BLOCK_COLUMN);
|
|
207
|
+
else
|
|
208
|
+
break;
|
|
209
|
+
}
|
|
210
|
+
return column;
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* A fenced code block delimiter: a run of three or more backticks or tildes
|
|
214
|
+
* indented at most three spaces, plus whatever follows it on the line (an
|
|
215
|
+
* info string on an opener, nothing on a valid closer).
|
|
216
|
+
*/
|
|
217
|
+
const FENCE = /^ {0,3}(`{3,}|~{3,})(.*)/;
|
|
218
|
+
/** The YAML front-matter delimiter: exactly three dashes; a longer run is a thematic break. */
|
|
219
|
+
const FRONT_MATTER = /^-{3}\s*$/;
|
|
220
|
+
const COMMENT_OPEN = '<!--';
|
|
221
|
+
const COMMENT_CLOSE = '-->';
|
|
222
|
+
/**
|
|
223
|
+
* Index of the first line after a leading YAML front-matter block, or 0 when
|
|
224
|
+
* the document opens with none. A list under `tags:` is metadata about the
|
|
225
|
+
* file, not the work it tracks.
|
|
226
|
+
*/
|
|
227
|
+
function skipFrontMatter(lines) {
|
|
228
|
+
if (lines.length === 0 || !FRONT_MATTER.test(lines[0].replace(/^/, '').trimEnd())) {
|
|
229
|
+
return 0;
|
|
230
|
+
}
|
|
231
|
+
for (let index = 1; index < lines.length; index++) {
|
|
232
|
+
if (FRONT_MATTER.test(lines[index].trimEnd()))
|
|
233
|
+
return index + 1;
|
|
234
|
+
}
|
|
235
|
+
// An unterminated opener is a thematic break, not front matter: every list
|
|
236
|
+
// in the document stays visible to the scan.
|
|
237
|
+
return 0;
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* 1-based line of the first list item in rendered content, skipping fenced
|
|
241
|
+
* code, HTML comments, YAML front matter and top-level indented code. Every
|
|
242
|
+
* exclusion can only silence the warning, never manufacture one.
|
|
243
|
+
*/
|
|
244
|
+
function findFirstListItemLine(content) {
|
|
245
|
+
const lines = content.split('\n');
|
|
246
|
+
let openFence;
|
|
247
|
+
let inComment = false;
|
|
248
|
+
let index = skipFrontMatter(lines);
|
|
249
|
+
for (; index < lines.length; index++) {
|
|
250
|
+
const line = lines[index];
|
|
251
|
+
if (inComment) {
|
|
252
|
+
if (line.includes(COMMENT_CLOSE))
|
|
253
|
+
inComment = false;
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
const fence = line.match(FENCE);
|
|
257
|
+
if (fence) {
|
|
258
|
+
const marker = fence[1][0];
|
|
259
|
+
const length = fence[1].length;
|
|
260
|
+
if (openFence === undefined) {
|
|
261
|
+
openFence = { marker, length };
|
|
262
|
+
}
|
|
263
|
+
else if (marker === openFence.marker && length >= openFence.length && fence[2].trim() === '') {
|
|
264
|
+
// CommonMark: a closer matches its opener's character, runs at least
|
|
265
|
+
// as long, and carries no info string.
|
|
266
|
+
openFence = undefined;
|
|
267
|
+
}
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
270
|
+
if (openFence !== undefined)
|
|
271
|
+
continue;
|
|
272
|
+
// Only a comment that opens the line hides it: `- [ ] 1.1 do it <!-- note`
|
|
273
|
+
// is a task line first.
|
|
274
|
+
if (line.trimStart().startsWith(COMMENT_OPEN)) {
|
|
275
|
+
if (!line.includes(COMMENT_CLOSE, line.indexOf(COMMENT_OPEN) + COMMENT_OPEN.length)) {
|
|
276
|
+
inComment = true;
|
|
277
|
+
}
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
if (LIST_ITEM.test(line) && indentColumns(line) < CODE_BLOCK_COLUMN)
|
|
281
|
+
return index + 1;
|
|
282
|
+
}
|
|
283
|
+
return undefined;
|
|
284
|
+
}
|
|
285
|
+
/**
|
|
286
|
+
* Warns when every file a change's schema tracks as tasks contains list
|
|
287
|
+
* items but not one checkbox. Undefined when the schema tracks no task file
|
|
288
|
+
* at all (a bare top-level `tasks.md` no artifact declares is not read by
|
|
289
|
+
* this check; warning about it would be a guess about a file the tool never
|
|
290
|
+
* opens), when the tracked set has at least one checkbox anywhere, when a
|
|
291
|
+
* tracked file could not be read (it may hold the checkboxes the unreadable
|
|
292
|
+
* file lost), or when no tracked file carries a bare list item either. The
|
|
293
|
+
* message opens with `<file relative to changeDir>:<line of the first bare
|
|
294
|
+
* list item>`.
|
|
295
|
+
*/
|
|
296
|
+
export function findMissingTaskCheckboxWarning(changeDir, projectRoot) {
|
|
297
|
+
const files = resolveTaskFilesForChange(changeDir, projectRoot);
|
|
298
|
+
if (files.length === 0)
|
|
299
|
+
return undefined;
|
|
300
|
+
const documents = [];
|
|
301
|
+
for (const file of files) {
|
|
302
|
+
let content;
|
|
303
|
+
try {
|
|
304
|
+
content = fs.readFileSync(file, 'utf-8');
|
|
305
|
+
}
|
|
306
|
+
catch {
|
|
307
|
+
return undefined;
|
|
308
|
+
}
|
|
309
|
+
documents.push({ path: file, content });
|
|
310
|
+
}
|
|
311
|
+
if (documents.some((document) => parseTaskLines(document.content).length > 0))
|
|
312
|
+
return undefined;
|
|
313
|
+
for (const document of documents) {
|
|
314
|
+
const line = findFirstListItemLine(document.content);
|
|
315
|
+
if (line !== undefined) {
|
|
316
|
+
return (`${path.relative(changeDir, document.path)}:${line} lists tasks but carries no checkbox, so progress has ` +
|
|
317
|
+
'nothing to flag as incomplete. Write each task as "- [ ] 1.1 Description".');
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
return undefined;
|
|
321
|
+
}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { type ChangeStatus } from '../../vendor/openspec/engine/index.js';
|
|
1
|
+
import { type ChangeStatus, type TaskProgress } from '../../vendor/openspec/engine/index.js';
|
|
2
2
|
export interface StatusOptions {
|
|
3
3
|
change?: string;
|
|
4
4
|
schema?: string;
|
|
5
5
|
json?: boolean;
|
|
6
6
|
}
|
|
7
7
|
export declare function statusHandler(projectRoot: string, options: StatusOptions): Promise<number>;
|
|
8
|
-
export declare function printStatusText(status: ChangeStatus): void;
|
|
8
|
+
export declare function printStatusText(status: ChangeStatus, taskProgress: TaskProgress): void;
|
|
@@ -5,8 +5,11 @@
|
|
|
5
5
|
// (artifacts[].status, artifacts[].requires, applyRequires, isPlanningComplete /
|
|
6
6
|
// isComplete). The store-only `root` field is omitted; `planningHome` is the
|
|
7
7
|
// store-free stub from shared.ts (V4-P4, #1703).
|
|
8
|
-
import { loadChangeContext, formatChangeStatus, getTaskProgressForChange, } from '../../vendor/openspec/engine/index.js';
|
|
9
|
-
import { changesDirFor, getAvailableChanges, planningHomeStub, printJson, validateChangeExists, validateSchemaExists, } from './shared.js';
|
|
8
|
+
import { loadChangeContext, formatChangeStatus, getTaskProgressForChange, formatTaskStatus, resolveNextStep, } from '../../vendor/openspec/engine/index.js';
|
|
9
|
+
import { changesDirFor, findMissingTaskCheckboxWarning, getAvailableChanges, planningHomeStub, printJson, validateChangeExists, validateSchemaExists, } from './shared.js';
|
|
10
|
+
import { collectSpecsIncompleteWarnings } from './instructions.js';
|
|
11
|
+
import { readUnknownMetadataKeys } from './validate.js';
|
|
12
|
+
import { tasksWithoutCheckboxWarning, unknownMetadataKeysWarning } from './warnings.js';
|
|
10
13
|
export async function statusHandler(projectRoot, options) {
|
|
11
14
|
if (!options.change) {
|
|
12
15
|
const available = getAvailableChanges(projectRoot);
|
|
@@ -31,17 +34,31 @@ export async function statusHandler(projectRoot, options) {
|
|
|
31
34
|
// checked, so the tasks artifact is complete for a reader's purposes exactly
|
|
32
35
|
// when `open` reaches zero.
|
|
33
36
|
const taskProgress = await getTaskProgressForChange(changesDirFor(projectRoot), changeName, projectRoot);
|
|
37
|
+
// Reports a tracked tasks file that lists work with no checkbox at all (#1774).
|
|
38
|
+
const checkboxWarning = findMissingTaskCheckboxWarning(context.changeDir, projectRoot);
|
|
39
|
+
const unknownKeys = readUnknownMetadataKeys(context.changeDir, projectRoot);
|
|
40
|
+
const warnings = [
|
|
41
|
+
...(checkboxWarning ? [tasksWithoutCheckboxWarning(checkboxWarning)] : []),
|
|
42
|
+
...(unknownKeys.length > 0 ? [unknownMetadataKeysWarning(unknownKeys)] : []),
|
|
43
|
+
...collectSpecsIncompleteWarnings(context.graph.getAllArtifacts(), context.changeDir, context.skippedArtifacts),
|
|
44
|
+
];
|
|
34
45
|
if (options.json) {
|
|
35
46
|
printJson({
|
|
36
47
|
...status,
|
|
37
48
|
done: taskProgress.done,
|
|
38
49
|
open: taskProgress.open,
|
|
39
50
|
dispositioned: taskProgress.dispositioned,
|
|
51
|
+
...(warnings.length > 0 ? { warnings } : {}),
|
|
40
52
|
planningHome: planningHomeStub(projectRoot),
|
|
41
53
|
});
|
|
42
54
|
return 0;
|
|
43
55
|
}
|
|
44
|
-
printStatusText(status);
|
|
56
|
+
printStatusText(status, taskProgress);
|
|
57
|
+
if (warnings.length > 0) {
|
|
58
|
+
console.log();
|
|
59
|
+
for (const warning of warnings)
|
|
60
|
+
console.log(`Warning: ${warning.message}`);
|
|
61
|
+
}
|
|
45
62
|
return 0;
|
|
46
63
|
}
|
|
47
64
|
function indicator(status) {
|
|
@@ -56,7 +73,7 @@ function indicator(status) {
|
|
|
56
73
|
return '[-]';
|
|
57
74
|
}
|
|
58
75
|
}
|
|
59
|
-
export function printStatusText(status) {
|
|
76
|
+
export function printStatusText(status, taskProgress) {
|
|
60
77
|
const doneCount = status.artifacts.filter((a) => a.status === 'done').length;
|
|
61
78
|
const skippedCount = status.artifacts.filter((a) => a.status === 'skipped').length;
|
|
62
79
|
const total = status.artifacts.length - skippedCount;
|
|
@@ -67,6 +84,7 @@ export function printStatusText(status) {
|
|
|
67
84
|
}
|
|
68
85
|
const skippedSuffix = skippedCount > 0 ? ` (${skippedCount} skipped)` : '';
|
|
69
86
|
console.log(`Progress: ${doneCount}/${total} artifacts complete${skippedSuffix}`);
|
|
87
|
+
console.log(`Tasks: ${formatTaskStatus(taskProgress)}`);
|
|
70
88
|
console.log();
|
|
71
89
|
for (const artifact of status.artifacts) {
|
|
72
90
|
let line = `${indicator(artifact.status)} ${artifact.id}`;
|
|
@@ -78,8 +96,19 @@ export function printStatusText(status) {
|
|
|
78
96
|
}
|
|
79
97
|
console.log(line);
|
|
80
98
|
}
|
|
81
|
-
|
|
99
|
+
// Same inputs as the JSON `nextSteps` sentence, so both surfaces name the same command.
|
|
100
|
+
const nextStep = resolveNextStep({
|
|
101
|
+
changeName: status.changeName,
|
|
102
|
+
artifactStatuses: status.artifacts,
|
|
103
|
+
allArtifactsComplete: status.isPlanningComplete,
|
|
104
|
+
});
|
|
105
|
+
if (status.isPlanningComplete || nextStep) {
|
|
82
106
|
console.log();
|
|
107
|
+
}
|
|
108
|
+
if (status.isPlanningComplete) {
|
|
83
109
|
console.log('All planning artifacts complete!');
|
|
84
110
|
}
|
|
111
|
+
if (nextStep) {
|
|
112
|
+
console.log(`Next: ${nextStep.command}`);
|
|
113
|
+
}
|
|
85
114
|
}
|
|
@@ -1,3 +1,12 @@
|
|
|
1
|
+
import { type TaskDisposition } from '../../vendor/openspec/validation/task-lines.js';
|
|
2
|
+
/** Why a disposition leaves its unchecked task open; null when it resolves it. */
|
|
3
|
+
export declare function dispositionReason(projectRoot: string, disposition: TaskDisposition): string | null;
|
|
4
|
+
/** The keys the change-metadata schema declares; a test keeps this equal to its zod shape. */
|
|
5
|
+
export declare const KNOWN_METADATA_KEYS: readonly string[];
|
|
6
|
+
/** Keys outside the schema and the `x-` namespace, stripped of control characters for printing. */
|
|
7
|
+
export declare function unknownMetadataKeys(metadata: Record<string, unknown>): string[];
|
|
8
|
+
/** Unreadable metadata yields none: validate reports that file as its own ERROR. */
|
|
9
|
+
export declare function readUnknownMetadataKeys(changeDir: string, projectRoot: string): string[];
|
|
1
10
|
export interface ValidateOptions {
|
|
2
11
|
specs?: boolean;
|
|
3
12
|
strict?: boolean;
|
|
@@ -23,15 +23,24 @@ import { discoverSpecFiles } from '../../vendor/openspec/validation/spec-discove
|
|
|
23
23
|
import { parseAnnotatedTaskLines } from '../../vendor/openspec/validation/task-lines.js';
|
|
24
24
|
import { readChangeMetadata } from '../../vendor/openspec/engine/change-metadata.js';
|
|
25
25
|
import { changesDirFor, getAvailableChanges, printJson, resolveChangeId, taskLabel } from './shared.js';
|
|
26
|
+
import { unknownMetadataKeysWarning } from './warnings.js';
|
|
26
27
|
const METADATA_FILENAME = '.openspec.yaml';
|
|
27
28
|
const TASKS_FILENAME = 'tasks.md';
|
|
28
29
|
const DISPOSITION_KINDS = new Set(['superseded', 'moved', 'external']);
|
|
29
|
-
/**
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
30
|
+
/** Why a disposition leaves its unchecked task open; null when it resolves it. */
|
|
31
|
+
export function dispositionReason(projectRoot, disposition) {
|
|
32
|
+
const { kind, target } = disposition;
|
|
33
|
+
if (!DISPOSITION_KINDS.has(kind)) {
|
|
34
|
+
return `disposition kind '${kind}' is none of superseded, moved, external`;
|
|
35
|
+
}
|
|
36
|
+
if (kind === 'external') {
|
|
37
|
+
return target.trim() === '' ? 'an external disposition needs a reference' : null;
|
|
38
|
+
}
|
|
39
|
+
return resolveChangeId(projectRoot, target) === null
|
|
40
|
+
? `${kind} disposition names unresolved change '${target}'`
|
|
41
|
+
: null;
|
|
42
|
+
}
|
|
43
|
+
/** ERRORs for a disposition under a checked task or one `dispositionReason` rejects. */
|
|
35
44
|
function dispositionIssues(projectRoot, changeDir) {
|
|
36
45
|
const tasksPath = path.join(changeDir, TASKS_FILENAME);
|
|
37
46
|
if (!fs.existsSync(tasksPath))
|
|
@@ -42,45 +51,43 @@ function dispositionIssues(projectRoot, changeDir) {
|
|
|
42
51
|
if (!task.disposition)
|
|
43
52
|
continue;
|
|
44
53
|
const label = taskLabel(task);
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
message: `task ${label}: a disposition belongs to an unchecked task`,
|
|
51
|
-
});
|
|
52
|
-
continue;
|
|
53
|
-
}
|
|
54
|
-
const { kind, target } = task.disposition;
|
|
55
|
-
if (!DISPOSITION_KINDS.has(kind)) {
|
|
56
|
-
issues.push({
|
|
57
|
-
level: 'ERROR',
|
|
58
|
-
path: TASKS_FILENAME,
|
|
59
|
-
line: task.line,
|
|
60
|
-
message: `task ${label}: disposition kind '${kind}' is none of superseded, moved, external`,
|
|
61
|
-
});
|
|
62
|
-
}
|
|
63
|
-
else if (kind === 'external') {
|
|
64
|
-
if (target.trim() === '') {
|
|
65
|
-
issues.push({
|
|
66
|
-
level: 'ERROR',
|
|
67
|
-
path: TASKS_FILENAME,
|
|
68
|
-
line: task.line,
|
|
69
|
-
message: `task ${label}: an external disposition needs a reference`,
|
|
70
|
-
});
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
else if (resolveChangeId(projectRoot, target) === null) {
|
|
74
|
-
issues.push({
|
|
75
|
-
level: 'ERROR',
|
|
76
|
-
path: TASKS_FILENAME,
|
|
77
|
-
line: task.line,
|
|
78
|
-
message: `task ${label}: ${kind} disposition names unresolved change '${target}'`,
|
|
79
|
-
});
|
|
54
|
+
const reason = task.done
|
|
55
|
+
? 'a disposition belongs to an unchecked task'
|
|
56
|
+
: dispositionReason(projectRoot, task.disposition);
|
|
57
|
+
if (reason) {
|
|
58
|
+
issues.push({ level: 'ERROR', path: TASKS_FILENAME, line: task.line, message: `task ${label}: ${reason}` });
|
|
80
59
|
}
|
|
81
60
|
}
|
|
82
61
|
return issues;
|
|
83
62
|
}
|
|
63
|
+
/** The keys the change-metadata schema declares; a test keeps this equal to its zod shape. */
|
|
64
|
+
export const KNOWN_METADATA_KEYS = ['schema', 'created', 'skip_specs', 'depends_on', 'supersedes'];
|
|
65
|
+
const EXTENSION_KEY_PREFIX = 'x-';
|
|
66
|
+
const CONTROL_CHARS = /[\u0000-\u001f\u007f-\u009f]/g;
|
|
67
|
+
/** Keys outside the schema and the `x-` namespace, stripped of control characters for printing. */
|
|
68
|
+
export function unknownMetadataKeys(metadata) {
|
|
69
|
+
return Object.keys(metadata)
|
|
70
|
+
.filter((key) => !KNOWN_METADATA_KEYS.includes(key) && !key.startsWith(EXTENSION_KEY_PREFIX))
|
|
71
|
+
.map((key) => key.replace(CONTROL_CHARS, ''));
|
|
72
|
+
}
|
|
73
|
+
/** Unreadable metadata yields none: validate reports that file as its own ERROR. */
|
|
74
|
+
export function readUnknownMetadataKeys(changeDir, projectRoot) {
|
|
75
|
+
try {
|
|
76
|
+
const metadata = readChangeMetadata(changeDir, projectRoot);
|
|
77
|
+
return metadata ? unknownMetadataKeys(metadata) : [];
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
return [];
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
/** Same level rule as the in-flight dependency notice, so a typo never trips --strict alone. */
|
|
84
|
+
function metadataKeyIssues(projectRoot, changeDir, strict) {
|
|
85
|
+
const keys = readUnknownMetadataKeys(changeDir, projectRoot);
|
|
86
|
+
if (keys.length === 0)
|
|
87
|
+
return [];
|
|
88
|
+
const level = strict ? 'INFO' : 'WARNING';
|
|
89
|
+
return [{ level, path: METADATA_FILENAME, message: unknownMetadataKeysWarning(keys).message }];
|
|
90
|
+
}
|
|
84
91
|
/**
|
|
85
92
|
* Extra issues from a change's `depends_on` / `supersedes` metadata: an ERROR
|
|
86
93
|
* for metadata that cannot be read, an ERROR for every id that resolves to
|
|
@@ -144,6 +151,7 @@ async function validateChangeItem(validator, projectRoot, id, strict) {
|
|
|
144
151
|
const issues = [
|
|
145
152
|
...report.issues,
|
|
146
153
|
...dependencyIssues(projectRoot, changeDir, strict),
|
|
154
|
+
...metadataKeyIssues(projectRoot, changeDir, strict),
|
|
147
155
|
...dispositionIssues(projectRoot, changeDir),
|
|
148
156
|
];
|
|
149
157
|
const errors = issues.filter((i) => i.level === 'ERROR').length;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { type NestedChangeFinding } from './nested-change.js';
|
|
2
|
+
export type ShimWarning = {
|
|
3
|
+
code: string;
|
|
4
|
+
message: string;
|
|
5
|
+
} & Record<string, unknown>;
|
|
6
|
+
export declare function tasksWithoutCheckboxWarning(message: string): ShimWarning;
|
|
7
|
+
export declare function nestedChangeWarning(finding: NestedChangeFinding): ShimWarning;
|
|
8
|
+
/** One warning covers every unknown id, so the text path prints a single line. */
|
|
9
|
+
export declare function unknownArtifactIdWarning(ids: string[]): ShimWarning;
|
|
10
|
+
/** `keys` arrive already stripped of control characters, since they come from an untrusted file. */
|
|
11
|
+
export declare function unknownMetadataKeysWarning(keys: string[]): ShimWarning;
|
|
12
|
+
/** `path` and `expected` are relative to the change directory. */
|
|
13
|
+
export declare function unreadDeltaFileWarning(changeName: string, file: {
|
|
14
|
+
path: string;
|
|
15
|
+
expected: string;
|
|
16
|
+
}): ShimWarning;
|
|
17
|
+
/** `spec` is the capability path, so a reader with several deltas knows which file holds the text. */
|
|
18
|
+
export declare function unappliedDeltaContentWarning(spec: string, message: string): ShimWarning;
|
|
19
|
+
/** `capabilities` are the proposal's listed names, flattened to one line each, with no `specs/<name>/spec.md`. */
|
|
20
|
+
export declare function specsIncompleteWarning(capabilities: string[]): ShimWarning;
|
|
21
|
+
export declare function missingDeltaSpecsWarning(changeName: string, specTarget: string, metadataPath: string): ShimWarning;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// The one element shape of every shim `--json` `warnings` array. `code` is a
|
|
2
|
+
// stable identifier a reader switches on; `message` equals the text the text
|
|
3
|
+
// output prints after its line prefix (`Warning: `, `- `), so the two surfaces
|
|
4
|
+
// never drift apart.
|
|
5
|
+
import { describeNestedChange } from './nested-change.js';
|
|
6
|
+
export function tasksWithoutCheckboxWarning(message) {
|
|
7
|
+
return { code: 'tasks_without_checkbox', message };
|
|
8
|
+
}
|
|
9
|
+
export function nestedChangeWarning(finding) {
|
|
10
|
+
return {
|
|
11
|
+
code: 'nested_change_directory',
|
|
12
|
+
name: finding.name,
|
|
13
|
+
nested: finding.nested,
|
|
14
|
+
message: describeNestedChange(finding),
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
/** One warning covers every unknown id, so the text path prints a single line. */
|
|
18
|
+
export function unknownArtifactIdWarning(ids) {
|
|
19
|
+
return {
|
|
20
|
+
code: 'unknown_artifact_id',
|
|
21
|
+
ids,
|
|
22
|
+
message: `Schema declares a dependency on an artifact id not defined in this schema: ${ids.join(', ')}.`,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
/** `keys` arrive already stripped of control characters, since they come from an untrusted file. */
|
|
26
|
+
export function unknownMetadataKeysWarning(keys) {
|
|
27
|
+
return {
|
|
28
|
+
code: 'unknown_metadata_keys',
|
|
29
|
+
keys,
|
|
30
|
+
message: `.openspec.yaml has unknown metadata key(s), ignored: ${keys.join(', ')}. ` +
|
|
31
|
+
'A misspelled key drops its setting; extension keys start with x-.',
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
/** `path` and `expected` are relative to the change directory. */
|
|
35
|
+
export function unreadDeltaFileWarning(changeName, file) {
|
|
36
|
+
const filePath = `specs/${file.path}`;
|
|
37
|
+
const expected = `specs/${file.expected}`;
|
|
38
|
+
return {
|
|
39
|
+
code: 'unread_delta_file',
|
|
40
|
+
path: filePath,
|
|
41
|
+
expected,
|
|
42
|
+
message: `${filePath} is not a capability's spec.md, so \`openspec validate ${changeName}\` rejects it and archive never merges it. ` +
|
|
43
|
+
`Move its requirements into ${expected}.`,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
/** `spec` is the capability path, so a reader with several deltas knows which file holds the text. */
|
|
47
|
+
export function unappliedDeltaContentWarning(spec, message) {
|
|
48
|
+
return { code: 'unapplied_delta_content', spec, message };
|
|
49
|
+
}
|
|
50
|
+
/** `capabilities` are the proposal's listed names, flattened to one line each, with no `specs/<name>/spec.md`. */
|
|
51
|
+
export function specsIncompleteWarning(capabilities) {
|
|
52
|
+
return {
|
|
53
|
+
code: 'specs_incomplete',
|
|
54
|
+
capabilities,
|
|
55
|
+
message: `The proposal lists capabilities with no delta spec yet: ${capabilities.join(', ')}. ` +
|
|
56
|
+
'Write specs/<capability>/spec.md for each before implementing, or drop it from the proposal.',
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
export function missingDeltaSpecsWarning(changeName, specTarget, metadataPath) {
|
|
60
|
+
return {
|
|
61
|
+
code: 'missing_delta_specs',
|
|
62
|
+
message: `This change has no delta specs and does not declare \`skip_specs: true\`, so \`openspec validate ${changeName}\` fails on it. ` +
|
|
63
|
+
`Write the delta specs before implementing (\`openspec instructions ${specTarget} --change ${changeName}\`), ` +
|
|
64
|
+
`or add \`skip_specs: true\` to ${metadataPath} if this change really changes no specified behavior.`,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export type LineEnding = '\n' | '\r\n';
|
|
2
|
+
export declare function detectLineEnding(content: string): LineEnding | undefined;
|
|
3
|
+
export declare function applyLineEnding(content: string, ending: LineEnding): string;
|
|
4
|
+
export declare function matchLineEnding(content: string, original: string): string;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// Line-ending helpers: parsers normalize CRLF to LF on read; these restore a
|
|
2
|
+
// file's own convention on write so an in-place edit does not rewrite every line.
|
|
3
|
+
export function detectLineEnding(content) {
|
|
4
|
+
const crlf = content.match(/\r\n/g)?.length ?? 0;
|
|
5
|
+
const lf = content.match(/(?<!\r)\n/g)?.length ?? 0;
|
|
6
|
+
if (crlf === 0 && lf === 0)
|
|
7
|
+
return undefined;
|
|
8
|
+
return crlf >= lf ? '\r\n' : '\n';
|
|
9
|
+
}
|
|
10
|
+
export function applyLineEnding(content, ending) {
|
|
11
|
+
const normalized = content.replace(/\r\n/g, '\n');
|
|
12
|
+
return ending === '\n' ? normalized : normalized.replace(/\n/g, '\r\n');
|
|
13
|
+
}
|
|
14
|
+
export function matchLineEnding(content, original) {
|
|
15
|
+
const ending = detectLineEnding(original);
|
|
16
|
+
return ending === undefined ? content : applyLineEnding(content, ending);
|
|
17
|
+
}
|