Files
msd-core/tests/profile-output.test.cjs
Tom Boucher 918f987a19 feat(#2982): extend no-source-grep lint to catch var-binding readFileSync.includes() (#2985)
* feat(#2982): extend no-source-grep lint to catch var-binding readFileSync.includes()

The base lint (scripts/lint-no-source-grep.cjs) only catches
readFileSync(...).<text-method>() chained directly. The much more
common var-binding form escapes it:

  const src = fs.readFileSync(p, 'utf8');
  // 50 lines later
  if (src.includes('foo')) {}        // ← still grep, lint missed it

Scan of the test suite found ~141 files using this pattern.

Implementation built TDD per #2982 with structured-IR assertions:

  scripts/lint-no-source-grep-extras.cjs
    - detectVarBindingViolations(src) — pure detector, two passes:
      pass 1 collects vars bound from readFileSync, pass 2 finds any
      <var>.<includes|startsWith|endsWith|match|search>( on those vars.
    - detectWrappedAssertOkMatch(src) — flags
      assert.ok(<expr>.match(...)) which escapes the assert.match rule.
    - VIOLATION enum exposes stable codes for tests to assert on.

  scripts/lint-no-source-grep.cjs
    - Wires the new detectors into the existing per-file check; one
      additional violation row per file with the first 3 sample tokens.

  tests/bug-2982-lint-var-binding.test.cjs
    - 13 tests, all assertions on typed VIOLATION enum / structured
      records. Covers all 5 text-match methods, multi-var, no-bind,
      string literal (must NOT trigger), wrapped assert.ok(.match),
      and assert.match (must NOT double-flag).

Migration backlog (#2974 expanded scope):

  - 42 files annotated `// allow-test-rule: source-text-is-the-product`
    (legitimate — they read .md/.json/.yml files whose deployed text
    IS the product)
  - 3 files annotated `// allow-test-rule: pending-migration-to-typed-ir [#2974]`
    (read .cjs/.js source — clear migration debt)
  - 95 files annotated `pending-migration-to-typed-ir [#2974]` with
    `Per-file review may reclassify as source-text-is-the-product
    during migration` (mixed — manual review under #2974)

After this lands the lint reports 0 violations on main; new
violations in PRs surface immediately.

Closes #2982
Refs #2974

* test(#2982): fix truncated test name per CR

The label ended with a bare '(' from a copy-paste mishap. Now reads
'does NOT flag .matchAll(...) — matchAll is not match, so
assert.ok(.matchAll(...)) is not flagged'.

* chore(#2982): add changeset fragment for PR #2985

* chore(#2982): add changeset fragment for PR #2985
2026-05-01 19:50:10 -04:00

215 lines
8.1 KiB
JavaScript

// allow-test-rule: source-text-is-the-product
// Reads .md/.json/.yml product files whose deployed text IS what the
// runtime loads — testing text content tests the deployed contract.
/**
* Profile Output Tests
*
* Tests for profile rendering commands and PROFILING_QUESTIONS data.
*/
const { test, describe, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const { runGsdTools, createTempProject, createTempGitProject, cleanup } = require('./helpers.cjs');
const {
PROFILING_QUESTIONS,
CLAUDE_INSTRUCTIONS,
} = require('../get-shit-done/bin/lib/profile-output.cjs');
// ─── PROFILING_QUESTIONS data ─────────────────────────────────────────────────
describe('PROFILING_QUESTIONS', () => {
test('is a non-empty array', () => {
assert.ok(Array.isArray(PROFILING_QUESTIONS));
assert.ok(PROFILING_QUESTIONS.length > 0);
});
test('each question has required fields', () => {
for (const q of PROFILING_QUESTIONS) {
assert.ok(q.dimension, `question missing dimension`);
assert.ok(q.header, `${q.dimension} missing header`);
assert.ok(q.question, `${q.dimension} missing question`);
assert.ok(Array.isArray(q.options), `${q.dimension} options should be array`);
assert.ok(q.options.length >= 2, `${q.dimension} should have at least 2 options`);
}
});
test('each option has label, value, and rating', () => {
for (const q of PROFILING_QUESTIONS) {
for (const opt of q.options) {
assert.ok(opt.label, `${q.dimension} option missing label`);
assert.ok(opt.value, `${q.dimension} option missing value`);
assert.ok(opt.rating, `${q.dimension} option missing rating`);
}
}
});
test('all dimension keys are unique', () => {
const dims = PROFILING_QUESTIONS.map(q => q.dimension);
const unique = [...new Set(dims)];
assert.strictEqual(dims.length, unique.length);
});
});
// ─── CLAUDE_INSTRUCTIONS ──────────────────────────────────────────────────────
describe('CLAUDE_INSTRUCTIONS', () => {
test('is a non-empty object', () => {
assert.ok(typeof CLAUDE_INSTRUCTIONS === 'object');
assert.ok(Object.keys(CLAUDE_INSTRUCTIONS).length > 0);
});
test('each dimension has at least one instruction', () => {
for (const [dim, instructions] of Object.entries(CLAUDE_INSTRUCTIONS)) {
assert.ok(typeof instructions === 'object', `${dim} should be an object`);
assert.ok(Object.keys(instructions).length > 0, `${dim} should have instructions`);
}
});
test('every PROFILING_QUESTIONS dimension has CLAUDE_INSTRUCTIONS', () => {
for (const q of PROFILING_QUESTIONS) {
assert.ok(
CLAUDE_INSTRUCTIONS[q.dimension],
`${q.dimension} has questions but no CLAUDE_INSTRUCTIONS`
);
}
});
});
// ─── write-profile command ────────────────────────────────────────────────────
describe('write-profile command', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('writes USER-PROFILE.md from analysis JSON', () => {
const analysis = {
profile_version: '1.0',
dimensions: {
communication_style: { rating: 'terse-direct', confidence: 'HIGH' },
decision_speed: { rating: 'fast-intuitive', confidence: 'MEDIUM' },
explanation_depth: { rating: 'concise', confidence: 'HIGH' },
debugging_approach: { rating: 'fix-first', confidence: 'LOW' },
ux_philosophy: { rating: 'function-first', confidence: 'MEDIUM' },
vendor_philosophy: { rating: 'pragmatic', confidence: 'HIGH' },
frustration_triggers: { rating: 'over-explanation', confidence: 'LOW' },
learning_style: { rating: 'hands-on', confidence: 'MEDIUM' },
},
};
const analysisPath = path.join(tmpDir, 'analysis.json');
fs.writeFileSync(analysisPath, JSON.stringify(analysis));
const result = runGsdTools(['write-profile', '--input', analysisPath, '--raw'], tmpDir);
assert.ok(result.success, `Failed: ${result.error}`);
const out = JSON.parse(result.output);
assert.ok(out.profile_path, 'should return profile_path');
assert.ok(out.dimensions_scored > 0, 'should have scored dimensions');
});
test('errors when --input is missing', () => {
const result = runGsdTools('write-profile --raw', tmpDir);
assert.ok(!result.success, 'should fail without --input');
assert.ok(result.error.includes('--input'), 'should mention --input');
});
});
// ─── generate-claude-md command ───────────────────────────────────────────────
describe('generate-claude-md command', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempGitProject();
fs.writeFileSync(
path.join(tmpDir, '.planning', 'PROJECT.md'),
'# My Project\n\nA test project.\n\n## Tech Stack\n\n- Node.js\n- TypeScript\n'
);
});
afterEach(() => {
cleanup(tmpDir);
});
test('generates CLAUDE.md with --auto flag', () => {
const outputPath = path.join(tmpDir, 'CLAUDE.md');
const result = runGsdTools(['generate-claude-md', '--output', outputPath, '--auto', '--raw'], tmpDir);
assert.ok(result.success, `Failed: ${result.error}`);
if (fs.existsSync(outputPath)) {
const content = fs.readFileSync(outputPath, 'utf-8');
assert.ok(content.length > 0, 'should have content');
}
});
test('does not overwrite existing CLAUDE.md without --force', () => {
const outputPath = path.join(tmpDir, 'CLAUDE.md');
fs.writeFileSync(outputPath, '# Custom CLAUDE.md\n\nUser content.\n');
const result = runGsdTools(['generate-claude-md', '--output', outputPath, '--auto', '--raw'], tmpDir);
// Should merge, not overwrite
const content = fs.readFileSync(outputPath, 'utf-8');
assert.ok(content.length > 0, 'should still have content');
});
test('skills fallback mentions the normalized project roots', () => {
const result = runGsdTools('generate-claude-md', tmpDir);
assert.ok(result.success, `Failed: ${result.error}`);
const content = fs.readFileSync(path.join(tmpDir, 'CLAUDE.md'), 'utf-8');
assert.ok(content.includes('.claude/skills/'));
assert.ok(content.includes('.agents/skills/'));
assert.ok(content.includes('.cursor/skills/'));
assert.ok(content.includes('.github/skills/'));
assert.ok(content.includes('.codex/skills/'));
assert.ok(!content.includes('get-shit-done/skills'));
});
});
// ─── generate-dev-preferences ─────────────────────────────────────────────────
describe('generate-dev-preferences command', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('errors when --analysis is missing', () => {
const result = runGsdTools('generate-dev-preferences --raw', tmpDir);
assert.ok(!result.success, 'should fail without --analysis');
assert.ok(result.error.includes('--analysis'), 'should mention --analysis');
});
test('generates preferences from analysis file', () => {
const analysis = {
profile_version: '1.0',
dimensions: {
communication_style: { rating: 'terse-direct', confidence: 'HIGH' },
decision_speed: { rating: 'fast-intuitive', confidence: 'MEDIUM' },
},
};
const analysisPath = path.join(tmpDir, 'analysis.json');
fs.writeFileSync(analysisPath, JSON.stringify(analysis));
const result = runGsdTools(['generate-dev-preferences', '--analysis', analysisPath, '--raw'], tmpDir);
assert.ok(result.success, `Failed: ${result.error}`);
const out = JSON.parse(result.output);
assert.ok(out.command_path || out.command_name, 'should return command output');
});
});